<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://gabrielkoo.com/feed.xml" rel="self" type="application/atom+xml"/><link href="https://gabrielkoo.com/" rel="alternate" type="text/html"/><updated>2026-08-23T12:03:38+00:00</updated><id>https://gabrielkoo.com/feed.xml</id><title type="html">Gabriel Koo</title><subtitle>AWS, Automation, Cloud, DevOps, GenAI, Security </subtitle><author><name>Gabriel Koo (AWS Community Builder)</name><email>hi@gabrielkoo.com</email></author><entry><title type="html">Why I Split-Tunnel My VPN for AI Services — and Let Cloudflare’s Application Library Pick the Domains</title><link href="https://gabrielkoo.com/blog/i-used-cloudflares-app-library-to-map-every-ai-services-domains-and-why-networking-still-matters-2and/" rel="alternate" type="text/html" title="Why I Split-Tunnel My VPN for AI Services — and Let Cloudflare’s Application Library Pick the Domains"/><published>2026-06-19T00:00:00+00:00</published><updated>2026-06-19T00:00:00+00:00</updated><id>https://gabrielkoo.com/blog/i-used-cloudflares-app-library-to-map-every-ai-services-domains-and-why-networking-still-matters-2and</id><content type="html" xml:base="https://gabrielkoo.com/blog/i-used-cloudflares-app-library-to-map-every-ai-services-domains-and-why-networking-still-matters-2and/"><![CDATA[<p>I maintain a small set of <strong>open-source GitHub repos</strong> that hold split-tunnel VPN configs for reaching AI services — <a href="https://github.com/gabrielkoo/tailscale-config-for-ai-services">Tailscale app connectors</a>, <a href="https://github.com/gabrielkoo/wireguard-configs-for-ai-services">WireGuard</a>, and <a href="https://github.com/gabrielkoo/openvpn-configs-for-ai-services">OpenVPN</a>. The single hardest part of maintaining them isn’t the VPN config. It’s answering one deceptively boring question:</p> <p><strong>Which domains does this AI platform actually use?</strong></p> <p>This post is about a shortcut I lean on — Cloudflare’s Application Library, queried over its REST API — and a broader point I keep coming back to: even in an era where AI can scaffold most of your code and config for you, <strong>networking fundamentals are still worth learning</strong>. Not because the AI can’t write the config, but because the difference between a clean setup and one that gets your account flagged comes down to a judgment call — <em>what you route and what you deliberately don’t</em> — and that judgment is exactly the part an AI can’t make for you without understanding your intent.</p> <p>(One caveat before we start: this is for routing traffic you’re already entitled to — not for evading restrictions that legitimately apply to you. Full legitimate-use note at the end.)</p> <h2 id="the-problem-a-domain-list-is-never-just-one-domain">The problem: a “domain list” is never just one domain</h2> <p>When you want to route only one app through a VPN, you need the set of hostnames (or IPs) that app actually talks to. That sounds trivial until you open DevTools on a modern web app and watch it fan out to twenty different domains: the main API, an auth provider, a CDN for static assets, telemetry, a fraud-detection pixel, a feature-flag service.</p> <p>Route too little and the app half-works (login spins forever, streaming responses stall). Route too much — say, the bare <code class="language-plaintext highlighter-rouge">*.cloudflarestorage.com</code> wildcard — and you scoop up huge amounts of <em>unrelated</em> traffic, which defeats the entire point of a split tunnel and can even get your exit node rate-limited.</p> <p>So you need the <strong>narrowest accurate</strong> set of hostnames. Where do you get it?</p> <p><img src="/assets/img/caaaed39c4d2.jpg" alt="One app fans out to many hostnames — a single ChatGPT app talking to api.openai.com, auth0.openai.com, cdn.oaistatic.com, oaiusercontent.com and more"/></p> <h2 id="the-shortcut-cloudflares-application-library">The shortcut: Cloudflare’s Application Library</h2> <p>Cloudflare’s Zero Trust product ships an <a href="https://developers.cloudflare.com/cloudflare-one/team-and-resources/app-library"><strong>Application Library</strong></a> — a catalog of well-known SaaS apps, each annotated with the hostnames Cloudflare has observed it using. It’s meant for admins building Access policies, but it doubles as a fantastic <em>domain-discovery</em> tool. Someone at Cloudflare already did the tedious traffic-watching for ChatGPT, Claude, and friends.</p> <p><img src="/assets/img/503230b95394.jpg" alt="Stop sniffing traffic by hand — the Cloudflare Application Library hands you the hostnames so you don't have to map them manually"/></p> <p>You can browse it in the dashboard under <strong>Zero Trust → Team &amp; Resources → Application Library</strong>. But I don’t want to click through a UI every time a provider quietly adds a domain — I want it in code. So I query the <a href="https://developers.cloudflare.com/api/">REST API</a>.</p> <p>Here’s the helper I use to pull ChatGPT’s hostnames. Pure stdlib, no SDK:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">json</span><span class="p">,</span> <span class="n">urllib</span><span class="p">.</span><span class="n">parse</span><span class="p">,</span> <span class="n">urllib</span><span class="p">.</span><span class="n">request</span>

<span class="n">CF_API</span> <span class="o">=</span> <span class="sh">"</span><span class="s">https://api.cloudflare.com/client/v4</span><span class="sh">"</span>


<span class="k">def</span> <span class="nf">cf_hostnames</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">account</span><span class="p">,</span> <span class="n">app_name</span><span class="p">,</span> <span class="n">search</span><span class="p">):</span>
    <span class="n">url</span> <span class="o">=</span> <span class="sh">"</span><span class="s">%s/accounts/%s/resource-library/applications?%s</span><span class="sh">"</span> <span class="o">%</span> <span class="p">(</span>
        <span class="n">CF_API</span><span class="p">,</span> <span class="n">account</span><span class="p">,</span> <span class="n">urllib</span><span class="p">.</span><span class="n">parse</span><span class="p">.</span><span class="nf">urlencode</span><span class="p">({</span><span class="sh">"</span><span class="s">search</span><span class="sh">"</span><span class="p">:</span> <span class="n">search</span><span class="p">,</span> <span class="sh">"</span><span class="s">limit</span><span class="sh">"</span><span class="p">:</span> <span class="mi">25</span><span class="p">}))</span>
    <span class="n">req</span> <span class="o">=</span> <span class="n">urllib</span><span class="p">.</span><span class="n">request</span><span class="p">.</span><span class="nc">Request</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">Authorization</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">Bearer </span><span class="sh">"</span> <span class="o">+</span> <span class="n">token</span><span class="p">})</span>
    <span class="k">with</span> <span class="n">urllib</span><span class="p">.</span><span class="n">request</span><span class="p">.</span><span class="nf">urlopen</span><span class="p">(</span><span class="n">req</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">30</span><span class="p">)</span> <span class="k">as</span> <span class="n">r</span><span class="p">:</span>
        <span class="n">d</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="n">r</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">a</span> <span class="ow">in</span> <span class="p">(</span><span class="n">d</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">result</span><span class="sh">"</span><span class="p">)</span> <span class="ow">or</span> <span class="p">[]):</span>
        <span class="k">if</span> <span class="n">a</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="p">)</span> <span class="o">==</span> <span class="n">app_name</span><span class="p">:</span>
            <span class="k">return</span> <span class="nf">sorted</span><span class="p">(</span><span class="nf">set</span><span class="p">(</span><span class="n">a</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">hostnames</span><span class="sh">"</span><span class="p">)</span> <span class="ow">or</span> <span class="p">[]))</span>
    <span class="k">raise</span> <span class="nc">SystemExit</span><span class="p">(</span><span class="sh">"</span><span class="s">CF: application %r not found (search=%r)</span><span class="sh">"</span> <span class="o">%</span> <span class="p">(</span><span class="n">app_name</span><span class="p">,</span> <span class="n">search</span><span class="p">))</span>
</code></pre></div></div> <p>A few things worth calling out:</p> <ul> <li><strong>The endpoint is <code class="language-plaintext highlighter-rouge">accounts/{account_id}/resource-library/applications</code>.</strong> It takes a <code class="language-plaintext highlighter-rouge">search</code> query and returns matching catalog entries, each with a <code class="language-plaintext highlighter-rouge">hostnames</code> array. I match on the exact <code class="language-plaintext highlighter-rouge">name</code> (e.g. <code class="language-plaintext highlighter-rouge">"ChatGPT"</code>, <code class="language-plaintext highlighter-rouge">"Claude"</code>) because a search can return several near-matches.</li> <li><strong>The token only needs read access</strong> to the resource library. Scope it minimally — there’s no reason this token should be able to change anything.</li> <li><strong>It’s deterministic and CI-friendly.</strong> No browser automation, no scraping. That matters for the next step.</li> <li><strong>You don’t need a paid plan.</strong> This lives in Cloudflare’s Zero Trust product, and Zero Trust has a <a href="https://blog.cloudflare.com/teams-plans/"><strong>free tier (up to 50 seats)</strong></a> that’s plenty for personal use. The Application Library and its REST API are available on that free plan — so the entire domain-discovery pipeline costs you nothing but an API token.</li> </ul> <h3 id="what-the-api-actually-returns">What the API actually returns</h3> <p>Here’s the real output for <strong>Claude</strong> (<code class="language-plaintext highlighter-rouge">name == "Claude"</code>) as of this writing:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"service"</span><span class="p">:</span><span class="w"> </span><span class="s2">"claude"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"application"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Claude"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"hostnames"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"a-api.anthropic.com"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"a-cdn.anthropic.com"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"anthropic.com"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"claude.ai"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"claude.com"</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p>Five hostnames, and notice they cover the things that actually matter: the web app (<code class="language-plaintext highlighter-rouge">claude.ai</code>, <code class="language-plaintext highlighter-rouge">claude.com</code>), the API (<code class="language-plaintext highlighter-rouge">a-api.anthropic.com</code>), the static asset CDN (<code class="language-plaintext highlighter-rouge">a-cdn.anthropic.com</code>), and the marketing/auth origin (<code class="language-plaintext highlighter-rouge">anthropic.com</code>). That’s the <em>narrow accurate set</em> I was after — nothing extraneous to prune.</p> <p>And the same call for <strong>ChatGPT</strong> (App ID <code class="language-plaintext highlighter-rouge">1199</code>):</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"service"</span><span class="p">:</span><span class="w"> </span><span class="s2">"openai"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"application"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ChatGPT"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"hostnames"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"api.openai.com"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"auth.openai.com"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"auth0.openai.com"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"cdn.oaistatic.com"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"chat.openai.com"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"chatgpt.com"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"oaistatic.com"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"oaiusercontent.com"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"openai.com"</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p>Nine hostnames, and the list is more revealing than it looks. Miss the Auth0-backed <code class="language-plaintext highlighter-rouge">auth0.openai.com</code> (still in the login path at time of writing) and sign-in silently hangs; miss <code class="language-plaintext highlighter-rouge">oaiusercontent.com</code> and uploads/generated files break. Try assembling that from DevTools by hand and you’ll miss the auth host you only hit on first login — then spend an afternoon debugging a broken login flow.</p> <p>Cloudflare already did the traffic-watching.</p> <h2 id="from-hostnames-to-a-routable-config">From hostnames to a routable config</h2> <p>Hostnames are perfect for <strong><a href="https://tailscale.com/kb/1281/app-connectors">Tailscale app connectors</a></strong>, which match on the domain name directly — Tailscale handles the DNS-to-IP mapping for you, so the config keeps working even when the provider rotates IPs.</p> <p>For <strong>WireGuard and OpenVPN</strong> — which you’d reach for when you need router-level enforcement, or you’re on a client where Tailscale isn’t an option — you can’t route by hostname; those tunnels route by IP. So the pipeline becomes:</p> <ol> <li><strong>Pull hostnames</strong> from the Cloudflare Application Library (the function above).</li> <li><strong>Resolve each to A + AAAA records</strong> over DNS-over-HTTPS, so the result doesn’t depend on whatever resolver the CI runner happens to use.</li> <li><strong>Aggregate</strong> the addresses to CIDR blocks, then collapse overlapping ranges. I default to <code class="language-plaintext highlighter-rouge">/24</code> (v4) and <code class="language-plaintext highlighter-rouge">/48</code> (v6), but understand this is a <em>heuristic</em>, not precision: expanding one resolved IP to a <code class="language-plaintext highlighter-rouge">/24</code> also routes the other 255 addresses on that edge node, some of which belong to unrelated tenants. It’s a deliberate trade — narrower (<code class="language-plaintext highlighter-rouge">/32</code>, <code class="language-plaintext highlighter-rouge">/28</code>) is more precise but churns more often; wider (<code class="language-plaintext highlighter-rouge">/20</code>, a supernet) means fewer entries but more collateral traffic. Pick the prefix that matches how much drift and collateral you can tolerate.</li> <li><strong>Union with a static “floor”</strong> for providers that publish a stable, authoritative prefix — for Anthropic that’s <code class="language-plaintext highlighter-rouge">160.79.104.0/21</code> (their own AS399230), so a single resolved <code class="language-plaintext highlighter-rouge">/24</code> never accidentally blackholes the rest of the block.</li> <li><strong>Rewrite the config files</strong> between marker comments, and let a <a href="https://github.com/gabrielkoo/wireguard-configs-for-ai-services/blob/main/.github/workflows/update-ips.yml">scheduled GitHub Action</a> open the change.</li> </ol> <p>The whole thing is one stdlib Python script (<a href="https://github.com/gabrielkoo/wireguard-configs-for-ai-services/blob/main/scripts/update_ips.py"><code class="language-plaintext highlighter-rouge">scripts/update_ips.py</code></a>) that runs on a cron schedule. The configs stay fresh without me touching them.</p> <p><img src="/assets/img/de6fa0294637.jpg" alt="The resolve-and-aggregate pipeline — scattered cloud hostnames funnel through resolve + aggregate into a single clean config"/></p> <p>And here’s where the two providers diverge in a way that proves the whole point. Run the resolve-and-aggregate step and <strong>ChatGPT</strong> collapses to a pile of Cloudflare ranges:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code># IPv4 (all shared Cloudflare anycast)
104.18.32.0/23
104.18.37.0/24
104.18.41.0/24
162.159.140.0/24
172.64.146.0/24
172.64.150.0/24
172.64.154.0/23
172.65.90.0/24
172.66.0.0/24

# IPv6 — 9 more Cloudflare /48s
</code></pre></div></div> <p>Every one of those is <strong>shared Cloudflare anycast</strong> — <a href="https://www.cloudflare.com/ips/"><code class="language-plaintext highlighter-rouge">104.18.x</code>, <code class="language-plaintext highlighter-rouge">172.64.x</code>, <code class="language-plaintext highlighter-rouge">172.66.x</code> are Cloudflare’s</a>, not OpenAI’s. Route them and you’re routing a slice of Cloudflare’s entire customer base, and the specific <code class="language-plaintext highlighter-rouge">/24</code>s will drift as Cloudflare reshuffles. That’s why ChatGPT-by-IP needs the scheduled refresh, and why it’s genuinely better as a Tailscale <em>hostname</em> connector.</p> <p><strong>Claude</strong> collapses to something completely different:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>34.36.57.0/24    ← Google Cloud LB (shared)
160.79.104.0/21  ← Anthropic's OWN block (AS399230)
2607:6bc0::/32   ← Anthropic's own IPv6
</code></pre></div></div> <p>The <code class="language-plaintext highlighter-rouge">160.79.104.0/21</code> is Anthropic’s own registered allocation — a <code class="language-plaintext highlighter-rouge">whois</code>/RDAP lookup shows the block (160.79.104.0–160.79.111.255) registered directly to Anthropic, PBC, announced via AS399230. So I can route the whole <code class="language-plaintext highlighter-rouge">/21</code> and trust <em>who</em> it belongs to, even though it’s 2,048 addresses — wider than Claude strictly needs today. That’s the deliberate stability-over-precision trade for a block whose owner won’t suddenly hand it to someone else. The <code class="language-plaintext highlighter-rouge">34.36.57.0/24</code> is a <a href="https://www.gstatic.com/ipranges/cloud.json">Google Cloud front-end</a> I let the script re-resolve each run rather than hardcode, because it <em>is</em> shared infrastructure that drifts. <strong>Same pipeline, two completely different risk profiles — and you only know which is which if you understand who owns the address space.</strong></p> <p><img src="/assets/img/25e7faa7e55b.jpg" alt="Same pipeline, two risk profiles — one resolve+aggregate funnel splits into ChatGPT on shared Cloudflare anycast that drifts, versus Claude on Anthropic's own stable 160.79.104.0/21 block"/></p> <h2 id="why-this-is-a-networking-lesson-not-a-vpn-lesson">Why this is a networking lesson, not a VPN lesson</h2> <p>Every step above is a networking decision, and getting them wrong has real consequences:</p> <p><img src="/assets/img/be11d59d9672.jpg" alt="Hostname routing vs IP routing — hostname routing follows the name and survives IP churn; IP routing pins addresses and breaks on drift"/></p> <ul> <li><strong>Hostname routing vs. IP routing is a fundamental tradeoff.</strong> DNS/SNI-based routing (Tailscale) survives IP churn and cleanly isolates one app on a shared CDN. IP-CIDR routing (WireGuard/OpenVPN) is simpler and dependency-free but brittle — it breaks when addresses drift, and it <em>can’t</em> separate two services that share an anycast front end. Knowing which tool fits which provider saves hours.</li> <li><strong>Shared anycast CDNs are a trap.</strong> <code class="language-plaintext highlighter-rouge">chatgpt.com</code> sits behind Cloudflare’s shared anycast — the same <code class="language-plaintext highlighter-rouge">/24</code>s serve thousands of unrelated sites. Route the whole block and you’ve quietly tunneled a chunk of the internet. Route too narrowly and it breaks tomorrow. You have to <em>understand</em> that the IP doesn’t belong to OpenAI to make a sane call.</li> </ul> <p><img src="/assets/img/6a15b83413ab.jpg" alt="Route a shared /24, scoop unrelated traffic — a net catches the one IP you wanted along with 254 strangers sharing the block"/></p> <ul> <li><strong>Knowing who owns an IP block matters.</strong> <code class="language-plaintext highlighter-rouge">160.79.104.0/21</code> is Anthropic’s own allocation (AS399230). That’s a stable, safe thing to route wholesale. A <code class="language-plaintext highlighter-rouge">34.36.x</code> Google Cloud load-balancer front-end in front of the same service is <em>not</em> — it’s shared infrastructure. A quick <code class="language-plaintext highlighter-rouge">whois</code> or a look at the AS tells you which is which.</li> <li><strong>Split tunneling is precision, and precision is how you stay out of trouble.</strong> This circles back to the disclaimer. The reason I route the <em>narrowest</em> set of hostnames isn’t tidiness — it’s that the less unrelated traffic I push through an exit node, the less I look like abuse, and the less I disrupt the security-sensitive services (banking, corporate SSO) that legitimately <em>don’t</em> want to see me arriving from a VPN IP. Full-tunneling everything is both lazier and riskier.</li> </ul> <p>None of this requires Cisco’s CCNA certification. But it does require knowing what a routing table is, what a CIDR block means, what an autonomous system is, and why DNS resolution is a separate concern from packet routing. That foundation turns “I copied a VPN config off the internet and hope it works” into “I know exactly what traffic goes where, and why.”</p> <h2 id="takeaways">Takeaways</h2> <ul> <li><strong>Cloudflare’s Application Library is an underrated domain-discovery tool</strong> — queryable over a simple REST endpoint, no scraping required.</li> <li><strong>Pick your routing primitive to match the provider:</strong> hostname-based for shared CDNs and IP-churning services, IP-CIDR for providers on their own stable address space.</li> <li><strong>Scope narrowly on purpose.</strong> It’s better for performance, it keeps your other services unaffected, and it keeps you on the right side of a provider’s abuse heuristics.</li> <li><strong>And stay legitimate.</strong> Use this for what you’re already entitled to use — careful routing, continuity while traveling — not for getting around restrictions that apply to you.</li> </ul> <p>If you want the runnable code, the three repos are linked at the top. The Cloudflare helper and the full resolve-and-aggregate pipeline live in <a href="https://github.com/gabrielkoo/wireguard-configs-for-ai-services/blob/main/scripts/update_ips.py"><code class="language-plaintext highlighter-rouge">scripts/update_ips.py</code></a>.</p> <h2 id="one-last-thing-the-legitimate-use-note">One last thing: the legitimate-use note</h2> <p>I parked this for the end so it didn’t bog down the technical walkthrough, but it matters. This whole approach is for one specific, legitimate situation: <strong>your account and home region are permitted to use the service, and you want precise control over your own traffic.</strong> Two concrete cases:</p> <ul> <li><strong>You want to route carefully — or deliberately NOT route — to avoid tripping a provider’s fraud/abuse checks.</strong> Many platforms run security engines (WAFs, fraud-detection, bot management) that get nervous when a logged-in session suddenly appears from a datacenter IP or a region that doesn’t match your account history. This cuts both ways: the same is true of <em>other</em> services you use — banks especially — which actively distrust traffic arriving from a VPN exit IP. Precision routing is as much about <em>keeping the wrong traffic off the tunnel</em> as putting the right traffic on it.</li> <li><strong>You normally reside in a region that’s allowed to use the service, but you’re temporarily somewhere it isn’t reachable</strong> — a short work trip, a conference, a layover. You’re a legitimate user of an unrestricted region who wants continuity of access you’re already entitled to.</li> </ul> <p>What this is <strong>not</strong> for: evading a restriction that legitimately applies to you. If your account or country of residence isn’t permitted to use a service, a VPN config doesn’t change that, and nothing here is an invitation to break a provider’s Terms of Service or your local law. <strong>Read the ToS, respect it, and when in doubt don’t.</strong> I keep my configs scoped as narrowly as possible precisely <em>because</em> the goal is to not look like abuse traffic.</p> <h2 id="sources">Sources</h2> <ul> <li><a href="https://developers.cloudflare.com/cloudflare-one/team-and-resources/app-library">Cloudflare — Application Library (Cloudflare One docs)</a></li> <li><a href="https://developers.cloudflare.com/api/">Cloudflare — REST API reference</a></li> <li><a href="https://tailscale.com/kb/1281/app-connectors">Tailscale — App connectors</a></li> <li><a href="https://www.cloudflare.com/ips/">Cloudflare — published IP ranges</a> · <a href="https://www.gstatic.com/ipranges/cloud.json">Google Cloud — published IP ranges</a> · <a href="https://rdap.arin.net/registry/ip/160.79.104.0">ARIN RDAP — <code class="language-plaintext highlighter-rouge">160.79.104.0/21</code> (Anthropic, PBC)</a></li> <li>Companion repos: <a href="https://github.com/gabrielkoo/tailscale-config-for-ai-services">Tailscale</a> · <a href="https://github.com/gabrielkoo/wireguard-configs-for-ai-services">WireGuard</a> · <a href="https://github.com/gabrielkoo/openvpn-configs-for-ai-services">OpenVPN</a></li> </ul>]]></content><author><name>Gabriel Koo (AWS Community Builder)</name><email>hi@gabrielkoo.com</email></author><summary type="html"><![CDATA[I maintain a small set of open-source GitHub repos that hold split-tunnel VPN configs for reaching AI...]]></summary></entry><entry><title type="html">Stop Putting API Keys in Your MCP Config: Per-User OAuth with Amazon Cognito + AWS Lambda</title><link href="https://gabrielkoo.com/blog/stop-putting-api-keys-in-mcpjson-per-user-oauth-with-amazon-cognito-aws-lambda-4h2i/" rel="alternate" type="text/html" title="Stop Putting API Keys in Your MCP Config: Per-User OAuth with Amazon Cognito + AWS Lambda"/><published>2026-06-07T00:00:00+00:00</published><updated>2026-06-07T00:00:00+00:00</updated><id>https://gabrielkoo.com/blog/stop-putting-api-keys-in-mcpjson-per-user-oauth-with-amazon-cognito-aws-lambda-4h2i</id><content type="html" xml:base="https://gabrielkoo.com/blog/stop-putting-api-keys-in-mcpjson-per-user-oauth-with-amazon-cognito-aws-lambda-4h2i/"><![CDATA[<p><em>The runnable companion to my AgentCon HK 2026 talk, <a href="https://the-quantum-nargle.github.io/agentcon-2026-hk-slides/">“Empower Team-Wide Vibe Coding with LLM Gateway and Security-First MCPs.”</a> The talk argued per-user OAuth is what turns a shared god-token into safe, auditable agent access. This is the wiring — explained at the architecture level. The full <code class="language-plaintext highlighter-rouge">template.yaml</code>, Lambda, and scripts live in the public repo linked at the end.</em></p> <hr/> <h2 id="the-gap-nobody-fills-your-identity-in-front-of-a-shared-key-api">The gap nobody fills: <em>your</em> identity in front of a shared-key API</h2> <p>By 2026 most big SaaS shipped an official MCP server, and many even support OAuth. So “my tool has no MCP” and “my tool has no OAuth” are both fading problems — chasing vendors that lack one is a losing game; the list shrinks every week.</p> <p>Tavily — the search API I use here — is actually a good citizen: its remote MCP supports both a shared key in the URL (<code class="language-plaintext highlighter-rouge">https://mcp.tavily.com/mcp/?tavilyApiKey=&lt;KEY&gt;</code>) <em>and</em> an OAuth flow. So why wrap it at all? Because even a vendor’s OAuth answers a different question than the one a security team is asking:</p> <ul> <li><strong>Shared keys are the default, and cost is why.</strong> Plenty of these tools price per seat. So the cheapest integration — the one teams actually ship — is one account’s API key wired into a single shared MCP for the whole group. The instant that key is shared, per-user identity is gone: every call is the same caller, and “who ran this?” has no answer. Vendor OAuth only helps if everyone pays for their own seat, which is exactly the bill teams are dodging.</li> <li><strong>Claimed ≠ enforced.</strong> Even when a tool <em>does</em> attribute per user, it often rides on a value the client supplies — Tavily’s <code class="language-plaintext highlighter-rouge">X-Human-Id</code> header is exactly this. The client asserts it, so nothing stops a caller from sending someone else’s; the attribution is a courtesy, not a control. Trustworthy attribution has to come from a token <em>your own</em> gateway cryptographically verified, not a string the client typed.</li> </ul> <p>And here’s the part that doesn’t shrink at all: <strong>the upstreams that matter most to you will never ship OAuth.</strong> Your internal billing API, the legacy claims system nobody wants to touch, the compliance-mandated third-party tool with a single team API key. For every one of those, the only auth is a shared key — shared by nature. And it’s not only internal systems: plenty of public vendors are the same shape — Brave Search’s own official MCP server, for instance, authenticates with a single <code class="language-plaintext highlighter-rouge">BRAVE_API_KEY</code> and no OAuth at all. One key, the whole team behind it.</p> <p>That’s the real, durable gap: <strong>no front door that authenticates callers as <em>your</em> identity, scopes them, and audits them — before handing off to a shared-key upstream.</strong> That’s what this post builds. Tavily is just a free, public stand-in you can actually run; mentally swap it for your own shared-key API.</p> <blockquote> <p>💡 <strong>The one idea:</strong> put a real OAuth 2.0 / OIDC authorization server bound to <em>your</em> identity (Amazon Cognito) — running the PKCE and client-credentials flows the MCP spec expects — in front of a shared-key upstream, in a single Lambda. Each caller gets their own scoped, cryptographically-verified, audited identity. The shared key never leaves the server.</p> </blockquote> <p><em>Want the 30-second version first? <a href="https://gabrielkoo.github.io/tavily-oauth-mcp-wrapper/">Walk through the interactive demo</a> — click through the whole OAuth flow, the scoped tool call, and the <code class="language-plaintext highlighter-rouge">403</code> when a caller reaches past its scope.</em></p> <h2 id="the-architecture">The architecture</h2> <p>Two pictures tell the whole story. The shared-key path — one key for everyone, whether it sits in a URL or a config file:</p> <p><img src="/assets/img/3c5b6ff475fb.png" alt="Shared-key access — three callers (two engineers and an agent) all funnel through one shared key, which then hits the upstream API. No per-user scope under your control, no audit you own, the key leaks into logs, and rotating it breaks the whole team."/></p> <p>With the wrapper, each caller arrives as themselves — verified against your own identity provider — and the key is locked away server-side:</p> <p><img src="/assets/img/14d02ac3c62f.png" alt="The OAuth wrapper — each caller logs in to its own session, hits an MCP server fronted by Amazon Cognito OAuth that enforces per-user scope and audit, and the shared key sits server-side (Secrets Manager / Parameter Store), reached only by the server before it calls the upstream. Scoped per identity, fully audited, key never leaves the server, and one session can be revoked without touching the team."/></p> <p>Three managed AWS pieces do the work, each with one clear job:</p> <p><strong>Amazon Cognito — the bouncer who issues the wristbands.</strong> It’s the OAuth 2.0 / OIDC authorization server (with PKCE — the piece OAuth 2.1 and the MCP spec lean on). A caller proves who they are <em>to Amazon Cognito</em>, which hands back a short-lived signed token stamped with a scope (here, <code class="language-plaintext highlighter-rouge">tavily-mcp/search</code>). It handles both kinds of caller from the same pool: a backend agent authenticates machine-to-machine, a human logs in through a hosted login page with PKCE. Crucially, Amazon Cognito <em>is</em> your identity layer — swapping the demo’s username/password for real corporate SSO (SAML, OIDC, or social logins like Google, Microsoft, Apple) is a configuration change, not a rebuild. The tokens, scopes, and everything downstream stay identical.</p> <p><strong>API Gateway HTTP API + its JWT authorizer — the wristband scanner at the door.</strong> Every request to the MCP endpoint — a plain JSON-RPC call over HTTP POST (the Streamable HTTP transport; this wrapper uses request/response POSTs only, not the transport’s optional SSE streaming channel) — hits API Gateway first. Its <a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-jwt-authorizer.html">built-in JWT authorizer</a> — a native feature of the HTTP API (v2) flavour; the older REST API would need a Cognito or custom Lambda authorizer instead — checks the token’s signature, issuer, and expiry <em>at the edge</em>, before a single line of your code runs. No token, expired token, forged token: rejected with a 401 right there. This is pure authentication: <em>are you who the token says you are?</em> Nothing reaches your logic until that passes. (One footgun worth flagging: Cognito access tokens carry no <code class="language-plaintext highlighter-rouge">aud</code> claim, so the authorizer’s audience must be set to the Cognito app client ID — a mismatch here is the most common source of silent 401s in this stack.)</p> <p><strong>Lambda — the room you’re actually allowed into.</strong> Once the token is valid, the Lambda does the <em>authorization</em>: it re-reads the scope to confirm this identity may call this specific tool, writes an audit line tying the action to that caller, then — and only then — reaches into Secrets Manager for the shared upstream key and calls Tavily. The key lives only inside this function’s execution role; it is never sent back to the client.</p> <p><img src="/assets/img/c700c0918921.png" alt="The deployed request path — MCP client sends an Amazon Cognito Bearer token; API Gateway's JWT authorizer validates it at the edge; the Lambda re-checks scope, logs the caller, and calls Tavily with a key it reads from Secrets Manager and never returns to the client."/></p> <p>The split is the whole point: <strong>API Gateway answers “is this a real, valid token?” and Lambda answers “is this identity allowed to do this, and let’s record that they did.”</strong> Authentication at the edge, authorization in your code, the secret sealed behind both.</p> <h2 id="why-type-http-not-type-stdio">Why <code class="language-plaintext highlighter-rouge">type: http</code>, not <code class="language-plaintext highlighter-rouge">type: stdio</code></h2> <p>There’s a reason this wrapper is a <em>remote</em> MCP server and not a local one — and it’s the same reason the talk put OAuth-fronted remote servers at the centre of the architecture. An MCP client config gives you three transport choices, and the choice quietly decides your entire security story:</p> <ul> <li><strong><code class="language-plaintext highlighter-rouge">stdio</code></strong> — the client spawns a local process (<code class="language-plaintext highlighter-rouge">npx some-mcp</code>, a Python script) and talks to it over stdin/stdout. The catch: that process needs the upstream credential <em>on the developer’s machine</em>. So the key lands in <code class="language-plaintext highlighter-rouge">mcp.json</code>, in an <code class="language-plaintext highlighter-rouge">env</code> block, in shell history, in a dotfile that gets synced to who-knows-where. Every laptop is now a copy of the shared key. This is the exact shape of the BYOAI / shared-key problem — one credential, sprayed everywhere, impossible to attribute or revoke cleanly.</li> <li><strong><code class="language-plaintext highlighter-rouge">sse</code></strong> — the older remote transport. Remote is the right instinct, but plain SSE was only ever a transport; it never carried an auth story of its own, so in practice people bolted a static bearer token onto it and landed right back at “one shared secret.”</li> <li><strong><code class="language-plaintext highlighter-rouge">http</code></strong> (Streamable HTTP) — a remote endpoint the client reaches over plain HTTPS, and the transport the 2025 MCP spec standardised on. Crucially, the <a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization">spec defines the MCP server as an <strong>OAuth resource server</strong></a> — so authentication is a first-class part of the transport, not an afterthought. The client config holds <em>no secret at all</em>; it just points at a URL and lets OAuth do the rest. (With an OAuth-native client the browser login is automatic; clients that don’t yet drive the flow themselves lean on a small local helper to do it — more on that below.)</li> </ul> <p>That last line is the whole pitch. Compare the two configs a developer actually writes:</p> <div class="language-jsonc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// stdio — the secret lives on every laptop</span><span class="w">
</span><span class="p">{</span><span class="w"> </span><span class="nl">"tavily"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"stdio"</span><span class="p">,</span><span class="w"> </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"npx"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"tavily-mcp"</span><span class="p">],</span><span class="w">
    </span><span class="nl">"env"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"TAVILY_API_KEY"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tvly-SHARED-KEY-everyone-has-this"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w">

</span><span class="c1">// http — no secret, OAuth per user</span><span class="w">
</span><span class="p">{</span><span class="w"> </span><span class="nl">"tavily"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http"</span><span class="p">,</span><span class="w"> </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://…/mcp"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p>The <code class="language-plaintext highlighter-rouge">http</code> version has nothing to leak. The credential never reaches the client; identity is established per-user through Cognito; and the server — not the laptop — is the only thing that ever touches the upstream key. Whenever an upstream <em>can</em> be reached as a remote OAuth-fronted <code class="language-plaintext highlighter-rouge">http</code> server, it should be. This wrapper exists precisely to turn a <code class="language-plaintext highlighter-rouge">stdio</code>-shaped shared-key tool into an <code class="language-plaintext highlighter-rouge">http</code>-shaped one.</p> <h2 id="one-endpoint-two-kinds-of-caller">One endpoint, two kinds of caller</h2> <p>A backend agent and a human engineer authenticate through completely different OAuth flows — <code class="language-plaintext highlighter-rouge">client_credentials</code> for the machine, <code class="language-plaintext highlighter-rouge">authorization_code</code> + PKCE for the person. But they end up carrying the <em>same</em> scope and hitting the <em>same</em> endpoint. The server treats them identically; the only difference is what lands in the audit log — a client ID for the machine, an email for the human.</p> <p><img src="/assets/img/f42275ee79db.png" alt="The two token origins converge on one MCP endpoint — a machine client via client_credentials and a human via authorization_code + PKCE both arrive carrying the same tavily-mcp/search scope, and the server treats them identically while logging a different caller identity."/></p> <h3 id="the-last-mile-most-mcp--oauth-posts-skip">The last mile most “MCP + OAuth” posts skip</h3> <p>One piece of the human flow is easy to gloss over, and it’s exactly where most “remote MCP + OAuth” walkthroughs quietly wave their hands: a static MCP client config (the <code class="language-plaintext highlighter-rouge">claude_desktop_config.json</code> kind) can’t pop a browser for a Cognito login on its own. The fix is a thin local helper — registered as the client’s <code class="language-plaintext highlighter-rouge">stdio</code> command — that checks for a cached token, triggers the PKCE browser login when it’s missing or expired, stashes the result, and forwards each request with the <code class="language-plaintext highlighter-rouge">Authorization: Bearer …</code> header. This is exactly what tools like <a href="https://github.com/geelen/mcp-remote"><code class="language-plaintext highlighter-rouge">mcp-remote</code></a> exist to do — and the <a href="https://github.com/geelen/mcp-remote/issues/251">rough edges around that login flow</a> are a recurring <a href="https://www.reddit.com/r/mcp/comments/1mw09b5/how_are_you_handling_oauth_when_running_mcp/">source of confusion</a> when people first wire up a remote MCP server. It’s a small shim, but without it the “human logs in with PKCE” line is doing a lot of unspoken work. The repo includes a runnable PKCE script you can wire in as that helper.</p> <h2 id="the-honest-limitation">The honest limitation</h2> <p><strong>The Lambda is the <em>final</em> enforcement point — but the security of the whole scheme rests on the entire chain: the JWT authorizer validating token integrity at the edge, and the Lambda enforcing scope behind it.</strong> The upstream still sees one shared key; it has no idea which human is behind a call. All the per-user identity, scoping, and audit live in <em>your</em> layer. So the scheme reduces to a few disciplines:</p> <ul> <li><strong>Lock the secret to only the Lambda’s execution role.</strong> Anyone who can read it gets full upstream access.</li> <li><strong>Make sure every route to the function goes through the JWT authorizer.</strong> A second unprotected trigger would bypass the whole thing.</li> <li><strong>Machine callers blur the human behind them.</strong> When a backend agent authenticates with <code class="language-plaintext highlighter-rouge">client_credentials</code>, the audit log names the <em>machine</em> client, not the person who prompted it. For a human-triggered agent you’ve narrowed “who” to one service identity, not one person. Closing that last gap means propagating the user’s own token down to the agent (OAuth 2.0 token exchange, RFC 8693) instead of falling back to a shared machine credential — worth knowing before you lean on M2M audit lines as proof of <em>who</em> did something.</li> <li><strong>Mind the 30-second ceiling.</strong> API Gateway HTTP APIs cap each integration at 30 seconds. A search call returns in well under a second, so it’s a non-issue here — but if you wrap a slower upstream (a heavy query, a multi-step scrape, a long-running agent tool), a call that overruns hits a <code class="language-plaintext highlighter-rouge">504</code> at the gateway. That’s the point where you’d reach for the transport’s SSE streaming channel, or an async submit-then-poll pattern, instead of a single blocking POST.</li> </ul> <p>Get those right and you’ve genuinely converted a shared god-token into per-user delegation. Get them wrong and you’ve just added a proxy in front of the same shared key. This is also why the <em>audit log</em> matters more than it looks: it’s the only place “who ran what” is recoverable at all — Tavily’s logs will forever show one key.</p> <h2 id="it-works">It works</h2> <p>Deployed to a real AWS account, the end-to-end path is exactly what you’d hope. A caller fetches an Amazon Cognito token, calls the MCP tool, and a real Tavily answer comes back <em>through the wrapper</em> — the client never touches the upstream key:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Answer: The Model Context Protocol (MCP) is a standardized framework
enabling AI models to access external data sources and tools securely…

1. What is the Model Context Protocol (MCP)?
   https://www.databricks.com/blog/what-is-model-context-protocol
</code></pre></div></div> <p>And the boundary holds: a request with <strong>no token, or a forged one, gets a <code class="language-plaintext highlighter-rouge">401</code> at the edge</strong> — rejected by API Gateway before the Lambda ever runs. Meanwhile every successful call writes an audit line naming the caller — <code class="language-plaintext highlighter-rouge">client 59psk…</code> or <code class="language-plaintext highlighter-rouge">demo@example.com</code> — something the upstream’s shared-key logs physically cannot produce. (Commands and full output are in the repo.)</p> <h2 id="why-this-generalizes">Why this generalizes</h2> <p>Swap Tavily for a legacy internal API, a SaaS whose own OAuth logs into <em>its</em> identity instead of yours, or a machine credential you don’t want sprayed across developer laptops — the Lambda is the only thing that changes, and the payoff is the same. A leaked Amazon Cognito access token is short-lived and expires on its own, and you cut a compromised identity off at the source by revoking its refresh token (and disabling the user/app client) so it can’t mint new ones — versus a leaked shared key, which means rotate-and-redeploy for the whole team. (Worth knowing: with the edge JWT authorizer, an already-issued access token stays valid until it expires; for instant kill-switch revocation you’d add a server-side check in the Lambda.) It ties straight back to the Golden Rule from the talk — <em>if a human can’t do it in the UI, the agent can’t do it via MCP</em> — because the agent inherits exactly the caller’s scope, nothing more. That’s what makes team-wide AI-assisted coding safe to roll out: developers plug a powerful shared-key tool straight into their own IDE, and security doesn’t have to say no, because there’s no shared credential on the laptop to leak — just the developer’s own scoped, revocable session.</p> <h2 id="what-does-all-this-cost-almost-nothing">What does all this cost? Almost nothing.</h2> <p>Here’s the part that makes this an easy yes: the entire control plane is serverless and pay-per-use, so for a team’s real workload it rounds to a rounding error. Amazon Cognito is free up to 50,000 monthly active users. Lambda’s free tier covers a million requests a month. An idle HTTP API costs nothing and is about $1 per million requests after that.</p> <p>The one real decision is where the shared upstream key lives. An encrypted Lambda environment variable is free but readable by anyone with <code class="language-plaintext highlighter-rouge">lambda:GetFunctionConfiguration</code> and baked into the deployment — fine for a demo, not for production. SSM Parameter Store <code class="language-plaintext highlighter-rouge">SecureString</code> is also free and gives the same <em>at-rest</em> protection for a static key (KMS-encrypted, IAM-scoped reads) — the sweet spot for most single-key wrappers. AWS Secrets Manager costs ~$0.40/month but adds the operational layer you may actually need: automatic rotation, resource policies, cross-account sharing. I used Secrets Manager in the reference because it makes the lockdown story explicit, but Parameter Store swaps in with a few lines. Either way the boundary is identical: IAM grants read to <em>only</em> the Lambda’s execution role, and the key never leaves the server.</p> <p>You also get <em>measurement</em> nearly for free: because every call carries a real identity, your Lambda can log structured per-user, per-tool fields, and CloudWatch Logs Insights (or an EMF metric) turns them into per-identity rate limits, anomaly alerts, or usage you bill back to a team. The shared-key world gives you one undifferentiated blob of traffic and none of that.</p> <p>Full source — <code class="language-plaintext highlighter-rouge">template.yaml</code>, the Lambda, both flow scripts, and a one-command teardown — is on GitHub. Clone it, point it at your own shared-key API, and you’ve got per-user OAuth in an afternoon.</p> <hr/> <h3 id="further-reading">Further reading</h3> <ul> <li><strong>The talk this builds on</strong> — <a href="https://the-quantum-nargle.github.io/agentcon-2026-hk-slides/">“Empower Team-Wide Vibe Coding with LLM Gateway and Security-First MCPs”</a> (Gabriel Koo &amp; Rakshit Jain, AgentCon HK 2026).</li> <li><strong>Interactive demo</strong> — <a href="https://gabrielkoo.github.io/tavily-oauth-mcp-wrapper/">click through the full OAuth flow</a> (login, scoped call, 403 on out-of-scope tool, and the <code class="language-plaintext highlighter-rouge">stdio</code> vs <code class="language-plaintext highlighter-rouge">http</code> config contrast).</li> <li><strong>Runnable demo &amp; full source</strong>: <a href="https://github.com/gabrielkoo/tavily-oauth-mcp-wrapper">github.com/gabrielkoo/tavily-oauth-mcp-wrapper</a></li> </ul>]]></content><author><name>Gabriel Koo (AWS Community Builder)</name><email>hi@gabrielkoo.com</email></author><summary type="html"><![CDATA[The runnable companion to my AgentCon HK 2026 talk, "Empower Team-Wide Vibe Coding with LLM Gateway...]]></summary></entry><entry><title type="html">Resurface Claude Code Usage Across Your Team with CloudWatch OTEL (No Lambda)</title><link href="https://gabrielkoo.com/blog/resurface-claude-code-usage-across-your-team-with-cloudwatch-otel-no-lambda-4p0i/" rel="alternate" type="text/html" title="Resurface Claude Code Usage Across Your Team with CloudWatch OTEL (No Lambda)"/><published>2026-04-18T00:00:00+00:00</published><updated>2026-04-18T00:00:00+00:00</updated><id>https://gabrielkoo.com/blog/resurface-claude-code-usage-across-your-team-with-cloudwatch-otel-no-lambda-4p0i</id><content type="html" xml:base="https://gabrielkoo.com/blog/resurface-claude-code-usage-across-your-team-with-cloudwatch-otel-no-lambda-4p0i/"><![CDATA[<p>I’ve been building AI tooling infrastructure to empower a team of 50+ software engineers to do vibe coding safely. We went from 3 engineers using AI full-time to 50+ in 6 months — including non-engineers. (I co-presented on this journey at <a href="https://the-quantum-nargle.github.io/agentcon-2026-hk-slides/">AgentCon Hong Kong 2026</a>.)</p> <p>One thing we learned: <strong>you can’t improve what you can’t measure.</strong> Once you give a team AI coding tools, you want visibility into how they’re being used — not to evaluate individual engineers, but to understand adoption patterns. Which tools are people reaching for? How large are the prompts? What tool calls are being made? Making these metrics visible to everyone helps the team learn from each other and helps champions pull others forward.</p> <p>This post is about the plumbing: how to get that telemetry data from coding agents into CloudWatch with minimal infrastructure.</p> <p><strong>“But we already have an LLM gateway.”</strong> If your team routes AI traffic through a gateway like <a href="https://github.com/BerriAI/litellm">LiteLLM</a> or <a href="https://aws.amazon.com/bedrock/">AWS Bedrock</a>, you already have token-level usage data. But if your engineers are on coding plans — Claude Team/Max, OpenCode Go, GitHub Copilot seats, ChatGPT Codex — the LLM calls bypass your gateway entirely. You lose visibility into the interesting stuff: how many tool calls per session, prompt sizes, which tools are being invoked, who’s active at what times. That’s where OTEL telemetry fills the gap.</p> <p>AI coding tools are shipping with built-in OpenTelemetry support. <a href="https://docs.anthropic.com/en/docs/claude-code/monitoring-usage">Claude Code</a>, <a href="https://support.claude.com/en/articles/14477985-monitor-claude-cowork-activity-with-opentelemetry">Claude CoWork</a>, <a href="https://docs.github.com/copilot/how-tos/copilot-sdk/observability/opentelemetry">GitHub Copilot</a>, <a href="https://geminicli.com/docs/cli/telemetry/">Gemini CLI</a>, and <a href="https://github.com/LangGuard-AI/cursor-otel-hook">Cursor</a> (via hooks) all export metrics, traces, and log events over OTLP/HTTP — token counts, tool durations, model latency, the works. <a href="https://github.com/kirodotdev/Kiro/issues/6319">Kiro has an open feature request</a> for native OTEL support too.</p> <p>There’s one catch: <strong>CloudWatch’s OTLP endpoints require SigV4 signing.</strong> These tools’ OTEL SDKs can’t do that. Neither can most OTEL SDKs without an AWS-specific exporter or a collector sidecar.</p> <p>The usual fix is a Lambda function that receives OTLP, signs it, and forwards it. That means cold starts, packaging, and another thing to maintain.</p> <p>Here’s a simpler way: <strong>API Gateway REST API with AWS Service Integration.</strong> APIGW signs the request with SigV4 using an execution role. No Lambda. No collector. No code.</p> <p><img src="/assets/img/51d37c7f020b.png" alt="Expanding Brain Meme"/></p> <blockquote> <p><strong>Timeline:</strong> CloudWatch has supported OTLP ingestion for <a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html">traces and logs</a> for some time (availability varies by region — check the <a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html">OTLP endpoints doc</a>). <a href="https://aws.amazon.com/about-aws/whats-new/2026/04/amazon-cloudwatch-opentelemetry-metrics/">Native OTLP metrics support launched April 2, 2026</a> in public preview, completing all three pillars of observability via OTLP.</p> </blockquote> <h2 id="architecture">Architecture</h2> <p><img src="/assets/img/91657a8ac5b6.png" alt="Architecture"/></p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AI Coding Tool (OTEL SDK)
  ↓ OTLP/HTTP + x-api-key
API Gateway REST API
  ├→ POST /v1/metrics  → AWS Integration → monitoring (SigV4) → CloudWatch Metrics
  ├→ POST /v1/traces   → AWS Integration → xray (SigV4)      → X-Ray / CloudWatch Logs
  └→ POST /v1/logs     → AWS Integration → logs (SigV4)       → CloudWatch Logs
</code></pre></div></div> <p>The client sends standard OTLP/HTTP requests with an API key. APIGW validates the key, assumes an IAM role, signs the request with SigV4, and forwards it to the CloudWatch OTLP endpoint. That’s it.</p> <h2 id="why-this-works">Why This Works</h2> <p>API Gateway REST API has an integration type called <strong><a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/getting-started-aws-proxy.html">AWS Service Integration</a></strong>. It can call any AWS service API and sign the request with SigV4 using an execution role. The <a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html">CloudWatch OTLP endpoints</a> are standard AWS service endpoints:</p> <table> <thead> <tr> <th>Signal</th> <th>Endpoint</th> <th>Service</th> </tr> </thead> <tbody> <tr> <td>Metrics</td> <td><code class="language-plaintext highlighter-rouge">monitoring.{region}.amazonaws.com/v1/metrics</code></td> <td><code class="language-plaintext highlighter-rouge">monitoring</code></td> </tr> <tr> <td>Traces</td> <td><code class="language-plaintext highlighter-rouge">xray.{region}.amazonaws.com/v1/traces</code></td> <td><code class="language-plaintext highlighter-rouge">xray</code></td> </tr> <tr> <td>Logs</td> <td><code class="language-plaintext highlighter-rouge">logs.{region}.amazonaws.com/v1/logs</code></td> <td><code class="language-plaintext highlighter-rouge">logs</code></td> </tr> </tbody> </table> <p>APIGW’s integration URI format maps directly:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>arn:aws:apigateway:{region}:monitoring:path/v1/metrics
arn:aws:apigateway:{region}:xray:path/v1/traces
arn:aws:apigateway:{region}:logs:path/v1/logs
</code></pre></div></div> <h2 id="setup">Setup</h2> <p>The full infrastructure is defined in a CloudFormation template (link at the bottom). Here’s what it creates:</p> <h3 id="iam-execution-role">IAM Execution Role</h3> <p>APIGW needs an IAM role to sign requests to CloudWatch. The policy is scoped to only the actions and resources needed for OTLP ingestion:</p> <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">Parameters</span><span class="pi">:</span>
  <span class="na">OtlpLogGroupName</span><span class="pi">:</span>
    <span class="na">Type</span><span class="pi">:</span> <span class="s">String</span>
    <span class="na">Default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">otlp-logs"</span>
    <span class="na">Description</span><span class="pi">:</span> <span class="s">CloudWatch Logs log group for OTLP log ingestion</span>

<span class="na">Resources</span><span class="pi">:</span>
  <span class="na">OtlpExecutionRole</span><span class="pi">:</span>
    <span class="na">Type</span><span class="pi">:</span> <span class="s">AWS::IAM::Role</span>
    <span class="na">Properties</span><span class="pi">:</span>
      <span class="na">AssumeRolePolicyDocument</span><span class="pi">:</span>
        <span class="na">Statement</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">Effect</span><span class="pi">:</span> <span class="s">Allow</span>
            <span class="na">Principal</span><span class="pi">:</span>
              <span class="na">Service</span><span class="pi">:</span> <span class="s">apigateway.amazonaws.com</span>
            <span class="na">Action</span><span class="pi">:</span> <span class="s">sts:AssumeRole</span>
      <span class="na">Policies</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">PolicyName</span><span class="pi">:</span> <span class="s">otlp-metrics</span>
          <span class="na">PolicyDocument</span><span class="pi">:</span>
            <span class="na">Statement</span><span class="pi">:</span>
              <span class="pi">-</span> <span class="na">Effect</span><span class="pi">:</span> <span class="s">Allow</span>
                <span class="na">Action</span><span class="pi">:</span>
                  <span class="pi">-</span> <span class="s">cloudwatch:PutMetricData</span>
                <span class="na">Resource</span><span class="pi">:</span> <span class="s2">"</span><span class="s">*"</span>
        <span class="pi">-</span> <span class="na">PolicyName</span><span class="pi">:</span> <span class="s">otlp-traces</span>
          <span class="na">PolicyDocument</span><span class="pi">:</span>
            <span class="na">Statement</span><span class="pi">:</span>
              <span class="pi">-</span> <span class="na">Effect</span><span class="pi">:</span> <span class="s">Allow</span>
                <span class="na">Action</span><span class="pi">:</span>
                  <span class="pi">-</span> <span class="s">xray:PutTraceSegments</span>
                  <span class="pi">-</span> <span class="s">xray:PutTelemetryRecords</span>
                <span class="na">Resource</span><span class="pi">:</span> <span class="s2">"</span><span class="s">*"</span>
        <span class="pi">-</span> <span class="na">PolicyName</span><span class="pi">:</span> <span class="s">otlp-logs</span>
          <span class="na">PolicyDocument</span><span class="pi">:</span>
            <span class="na">Statement</span><span class="pi">:</span>
              <span class="pi">-</span> <span class="na">Effect</span><span class="pi">:</span> <span class="s">Allow</span>
                <span class="na">Action</span><span class="pi">:</span>
                  <span class="pi">-</span> <span class="s">logs:PutLogEvents</span>
                  <span class="pi">-</span> <span class="s">logs:CreateLogStream</span>
                  <span class="pi">-</span> <span class="s">logs:DescribeLogStreams</span>
                <span class="na">Resource</span><span class="pi">:</span>
                  <span class="pi">-</span> <span class="kt">!Sub</span> <span class="s2">"</span><span class="s">arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${OtlpLogGroupName}:*"</span>
</code></pre></div></div> <p>Note: <code class="language-plaintext highlighter-rouge">cloudwatch:PutMetricData</code> doesn’t support resource-level ARNs. The <code class="language-plaintext highlighter-rouge">cloudwatch:namespace</code> condition key exists but does not apply to the OTLP ingestion path — metrics are accepted regardless of namespace. X-Ray <code class="language-plaintext highlighter-rouge">PutTraceSegments</code> also doesn’t support resource-level restrictions. Logs permissions are scoped to a specific log group via the <code class="language-plaintext highlighter-rouge">OtlpLogGroupName</code> parameter.</p> <h3 id="api-gateway-with-aws-service-integration">API Gateway with AWS Service Integration</h3> <p>Each OTLP signal gets its own resource with an AWS integration:</p> <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">MetricsMethod</span><span class="pi">:</span>
  <span class="na">Type</span><span class="pi">:</span> <span class="s">AWS::ApiGateway::Method</span>
  <span class="na">Properties</span><span class="pi">:</span>
    <span class="na">HttpMethod</span><span class="pi">:</span> <span class="s">POST</span>
    <span class="na">AuthorizationType</span><span class="pi">:</span> <span class="s">NONE</span>
    <span class="na">ApiKeyRequired</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">Integration</span><span class="pi">:</span>
      <span class="na">Type</span><span class="pi">:</span> <span class="s">AWS</span>
      <span class="na">IntegrationHttpMethod</span><span class="pi">:</span> <span class="s">POST</span>
      <span class="na">Uri</span><span class="pi">:</span> <span class="kt">!Sub</span> <span class="s2">"</span><span class="s">arn:aws:apigateway:${AWS::Region}:monitoring:path/v1/metrics"</span>
      <span class="na">Credentials</span><span class="pi">:</span> <span class="kt">!GetAtt</span> <span class="s">OtlpExecutionRole.Arn</span>
      <span class="na">PassthroughBehavior</span><span class="pi">:</span> <span class="s">WHEN_NO_MATCH</span>
      <span class="na">ContentHandling</span><span class="pi">:</span> <span class="s">CONVERT_TO_TEXT</span>
</code></pre></div></div> <p>Same pattern for <code class="language-plaintext highlighter-rouge">/v1/traces</code> (service: <code class="language-plaintext highlighter-rouge">xray</code>) and <code class="language-plaintext highlighter-rouge">/v1/logs</code> (service: <code class="language-plaintext highlighter-rouge">logs</code>).</p> <h3 id="api-key-authentication">API Key Authentication</h3> <p>Protect the endpoint with an API key so only your tools can send telemetry:</p> <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">ApiKey</span><span class="pi">:</span>
  <span class="na">Type</span><span class="pi">:</span> <span class="s">AWS::ApiGateway::ApiKey</span>
  <span class="na">Properties</span><span class="pi">:</span>
    <span class="na">Enabled</span><span class="pi">:</span> <span class="kc">true</span>

<span class="na">UsagePlan</span><span class="pi">:</span>
  <span class="na">Type</span><span class="pi">:</span> <span class="s">AWS::ApiGateway::UsagePlan</span>
  <span class="na">Properties</span><span class="pi">:</span>
    <span class="na">ApiStages</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">ApiId</span><span class="pi">:</span> <span class="kt">!Ref</span> <span class="s">Api</span>
        <span class="na">Stage</span><span class="pi">:</span> <span class="kt">!Ref</span> <span class="s">Stage</span>
</code></pre></div></div> <h2 id="configure-your-tools">Configure Your Tools</h2> <p>The proxy works with any tool that supports standard OTEL environment variables. Here’s how to configure each:</p> <blockquote> <p><strong>Disclaimer:</strong> I personally use Claude Code routed through a custom LLM gateway (not a coding plan), since some coding plans aren’t available in the region I live in. The configurations below are based on each tool’s official documentation — your mileage may vary.</p> </blockquote> <h3 id="claude-code">Claude Code</h3> <p><a href="https://docs.anthropic.com/en/docs/claude-code/monitoring-usage">Official monitoring docs</a></p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">CLAUDE_CODE_ENABLE_TELEMETRY</span><span class="o">=</span>1
<span class="nb">export </span><span class="nv">OTEL_METRICS_EXPORTER</span><span class="o">=</span>otlp
<span class="nb">export </span><span class="nv">OTEL_LOGS_EXPORTER</span><span class="o">=</span>otlp
<span class="nb">export </span><span class="nv">OTEL_EXPORTER_OTLP_PROTOCOL</span><span class="o">=</span>http/json
<span class="nb">export </span><span class="nv">OTEL_EXPORTER_OTLP_ENDPOINT</span><span class="o">=</span>https://xxx.execute-api.us-west-2.amazonaws.com/prod
<span class="nb">export </span><span class="nv">OTEL_EXPORTER_OTLP_HEADERS</span><span class="o">=</span>x-api-key<span class="o">=</span>your-api-key
<span class="nb">export </span><span class="nv">OTEL_SERVICE_NAME</span><span class="o">=</span>claude-code
</code></pre></div></div> <p>For short-lived tasks, lower the export interval so data flushes before the process exits:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">OTEL_METRIC_EXPORT_INTERVAL</span><span class="o">=</span>1000
<span class="nb">export </span><span class="nv">OTEL_LOGS_EXPORT_INTERVAL</span><span class="o">=</span>1000
</code></pre></div></div> <p>For traces (beta):</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">CLAUDE_CODE_ENHANCED_TELEMETRY_BETA</span><span class="o">=</span>1
<span class="nb">export </span><span class="nv">OTEL_TRACES_EXPORTER</span><span class="o">=</span>otlp
<span class="nb">export </span><span class="nv">OTEL_TRACES_EXPORT_INTERVAL</span><span class="o">=</span>1000
</code></pre></div></div> <p><strong>Enforcing OTEL across your team:</strong> Claude Code supports <a href="https://code.claude.com/docs/en/settings#settings-files">managed settings</a> via <code class="language-plaintext highlighter-rouge">managed-settings.json</code>, deployable through MDM (Jamf, Intune, etc.). This lets you enforce OTEL configuration org-wide — engineers don’t need to set environment variables manually, and they can’t opt out.</p> <h3 id="claude-cowork-team--enterprise">Claude CoWork (Team &amp; Enterprise)</h3> <p><a href="https://support.claude.com/en/articles/14477985-monitor-claude-cowork-activity-with-opentelemetry">CoWork monitoring docs</a> — configure via Admin Settings → Cowork → Monitoring:</p> <ul> <li>OTLP endpoint: your APIGW URL</li> <li>OTLP protocol: <code class="language-plaintext highlighter-rouge">http/json</code></li> <li>OTLP headers: <code class="language-plaintext highlighter-rouge">x-api-key=your-api-key</code></li> </ul> <p>CoWork streams user prompts, tool/MCP invocations, file access, human approval decisions, and API request details. It shares the same OTel event schema as Claude Code via the Claude Agent SDK — you can distinguish them by <code class="language-plaintext highlighter-rouge">terminal.type</code> (<code class="language-plaintext highlighter-rouge">cowork</code> vs <code class="language-plaintext highlighter-rouge">cli</code>).</p> <h3 id="github-copilot-cli">GitHub Copilot CLI</h3> <p><a href="https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference#opentelemetry-monitoring">Copilot CLI OTel reference</a> — available since Copilot CLI 1.0.4:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">COPILOT_OTEL_ENDPOINT</span><span class="o">=</span>https://xxx.execute-api.us-west-2.amazonaws.com/prod
<span class="nb">export </span><span class="nv">COPILOT_OTEL_HEADERS</span><span class="o">=</span>x-api-key<span class="o">=</span>your-api-key
</code></pre></div></div> <h3 id="gemini-cli">Gemini CLI</h3> <p><a href="https://geminicli.com/docs/cli/telemetry/">Gemini CLI telemetry docs</a></p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">GEMINI_CLI_OTEL_EXPORT_ENDPOINT</span><span class="o">=</span>https://xxx.execute-api.us-west-2.amazonaws.com/prod
</code></pre></div></div> <h3 id="cursor-via-hooks">Cursor (via Hooks)</h3> <p>Cursor doesn’t have native OTEL export yet, but the community <a href="https://github.com/LangGuard-AI/cursor-otel-hook">cursor-otel-hook</a> project captures agent activity via Cursor’s hook system and exports traces to any OTLP endpoint. Configure via <code class="language-plaintext highlighter-rouge">otel_config.json</code>:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"OTEL_EXPORTER_OTLP_ENDPOINT"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://xxx.execute-api.us-west-2.amazonaws.com/prod/v1/traces"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"OTEL_EXPORTER_OTLP_PROTOCOL"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http/json"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"OTEL_EXPORTER_OTLP_HEADERS"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"x-api-key"</span><span class="p">:</span><span class="w"> </span><span class="s2">"your-api-key"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <h2 id="what-you-get">What You Get</h2> <p>CloudWatch receives standard OTLP data. For Claude Code specifically:</p> <ul> <li><strong>Metrics</strong>: <code class="language-plaintext highlighter-rouge">claude_code.token.usage</code> (by <code class="language-plaintext highlighter-rouge">token.type</code>: input/output/cache_read/cache_creation), <code class="language-plaintext highlighter-rouge">claude_code.cost.usage</code> (USD), <code class="language-plaintext highlighter-rouge">claude_code.session.count</code>, <code class="language-plaintext highlighter-rouge">claude_code.lines_of_code.count</code></li> <li><strong>Traces</strong> (beta): Spans linking each user prompt → API requests → tool executions</li> <li><strong>Log events</strong>: <code class="language-plaintext highlighter-rouge">claude_code.user_prompt</code>, <code class="language-plaintext highlighter-rouge">claude_code.tool_decision</code>, <code class="language-plaintext highlighter-rouge">claude_code.tool_result</code>, <code class="language-plaintext highlighter-rouge">claude_code.api_request</code> — all tagged with <code class="language-plaintext highlighter-rouge">session.id</code> and <code class="language-plaintext highlighter-rouge">service.name=claude-code</code></li> </ul> <p>Here’s what real Claude Code log events look like after flowing through the proxy into CloudWatch Logs. This is actual data from an E2E test — a single prompt that triggered a Bash tool call:</p> <p><strong><code class="language-plaintext highlighter-rouge">claude_code.user_prompt</code></strong> — emitted when the user sends a prompt:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"resource"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"attributes"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"host.arch"</span><span class="p">:</span><span class="w"> </span><span class="s2">"arm64"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"os.type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"linux"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"service.name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"claude-code"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"service.version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2.1.114"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"os.version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"6.17.0-1010-aws"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"scope"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"com.anthropic.claude_code.events"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2.1.114"</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"body"</span><span class="p">:</span><span class="w"> </span><span class="s2">"claude_code.user_prompt"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"attributes"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"event.sequence"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"user.id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1c257d04..."</span><span class="p">,</span><span class="w">
    </span><span class="nl">"prompt_length"</span><span class="p">:</span><span class="w"> </span><span class="s2">"40"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"terminal.type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"non-interactive"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"event.name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"user_prompt"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"event.timestamp"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-04-18T11:23:13.187Z"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"prompt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"&lt;REDACTED&gt;"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"session.id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"846ab649-8bba-471e-8ec5-8756116d0840"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"prompt.id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"88475ee2-59c2-4137-9201-5540c6a6cad1"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p><strong><code class="language-plaintext highlighter-rouge">claude_code.tool_result</code></strong> — emitted after each tool execution:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"body"</span><span class="p">:</span><span class="w"> </span><span class="s2">"claude_code.tool_result"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"attributes"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"tool_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Bash"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"tool_result_size_bytes"</span><span class="p">:</span><span class="w"> </span><span class="s2">"899"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"tool_input"</span><span class="p">:</span><span class="w"> </span><span class="s2">"{</span><span class="se">\"</span><span class="s2">command</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="s2">ls</span><span class="se">\"</span><span class="s2">,</span><span class="se">\"</span><span class="s2">description</span><span class="se">\"</span><span class="s2">:</span><span class="se">\"</span><span class="s2">List files in current directory</span><span class="se">\"</span><span class="s2">}"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"duration_ms"</span><span class="p">:</span><span class="w"> </span><span class="s2">"95"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"success"</span><span class="p">:</span><span class="w"> </span><span class="s2">"true"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"session.id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"846ab649-8bba-471e-8ec5-8756116d0840"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"prompt.id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"88475ee2-59c2-4137-9201-5540c6a6cad1"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p><strong><code class="language-plaintext highlighter-rouge">claude_code.api_request</code></strong> — emitted after each API call with token counts and cost:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"body"</span><span class="p">:</span><span class="w"> </span><span class="s2">"claude_code.api_request"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"attributes"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"claude-sonnet-4-5-20250929"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"input_tokens"</span><span class="p">:</span><span class="w"> </span><span class="s2">"142"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"output_tokens"</span><span class="p">:</span><span class="w"> </span><span class="s2">"61"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"cache_read_tokens"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"cache_creation_tokens"</span><span class="p">:</span><span class="w"> </span><span class="s2">"25848"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"cost_usd"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0.098271"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"duration_ms"</span><span class="p">:</span><span class="w"> </span><span class="s2">"4950"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"speed"</span><span class="p">:</span><span class="w"> </span><span class="s2">"normal"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"session.id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"846ab649-8bba-471e-8ec5-8756116d0840"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"prompt.id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"88475ee2-59c2-4137-9201-5540c6a6cad1"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p>All events share the same <code class="language-plaintext highlighter-rouge">prompt.id</code>, linking them into a single interaction. The <code class="language-plaintext highlighter-rouge">event.sequence</code> field orders events within a prompt. Every record carries <code class="language-plaintext highlighter-rouge">service.name=claude-code</code> in resource attributes, so isolating Claude Code telemetry in a mixed pipeline is trivial — just filter on that in CloudWatch Logs Insights:</p> <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">fields</span> <span class="o">@</span><span class="nb">timestamp</span><span class="p">,</span> <span class="n">body</span><span class="p">,</span> <span class="n">attributes</span><span class="p">.</span><span class="n">model</span><span class="p">,</span> <span class="n">attributes</span><span class="p">.</span><span class="n">cost_usd</span><span class="p">,</span> <span class="n">attributes</span><span class="p">.</span><span class="n">duration_ms</span>
<span class="o">|</span> <span class="n">filter</span> <span class="n">resource</span><span class="p">.</span><span class="n">attributes</span><span class="p">.</span><span class="nv">`service.name`</span> <span class="o">=</span> <span class="s1">'claude-code'</span>
<span class="o">|</span> <span class="n">filter</span> <span class="n">body</span> <span class="o">=</span> <span class="s1">'claude_code.api_request'</span>
<span class="o">|</span> <span class="n">sort</span> <span class="o">@</span><span class="nb">timestamp</span> <span class="k">desc</span>
</code></pre></div></div> <h2 id="region-availability">Region Availability</h2> <p>CloudWatch OTLP endpoints are available in most regions but <strong>not all</strong>. The <a href="https://aws.amazon.com/about-aws/whats-new/2026/04/amazon-cloudwatch-opentelemetry-metrics/">OTLP metrics preview</a> launched in 5 regions:</p> <table> <thead> <tr> <th>Signal</th> <th>Regions</th> <th>Docs</th> </tr> </thead> <tbody> <tr> <td>Metrics (preview)</td> <td>us-east-1, us-west-2, ap-southeast-1, ap-southeast-2, eu-west-1</td> <td><a href="https://aws.amazon.com/about-aws/whats-new/2026/04/amazon-cloudwatch-opentelemetry-metrics/">Announcement</a></td> </tr> <tr> <td>Traces</td> <td>Most commercial regions</td> <td><a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html">OTLP Endpoints</a></td> </tr> <tr> <td>Logs</td> <td>Most commercial regions</td> <td><a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html">OTLP Endpoints</a></td> </tr> </tbody> </table> <p>Tested and confirmed:</p> <table> <thead> <tr> <th>Region</th> <th>Metrics</th> <th>Traces</th> <th>Logs</th> </tr> </thead> <tbody> <tr> <td>us-east-1</td> <td>✅</td> <td>✅</td> <td>✅</td> </tr> <tr> <td>us-west-2</td> <td>✅</td> <td>✅</td> <td>✅</td> </tr> <tr> <td>ap-southeast-1</td> <td>✅</td> <td>✅</td> <td>✅</td> </tr> <tr> <td>ap-east-1 (Hong Kong)</td> <td>❌</td> <td>❌</td> <td>❌</td> </tr> </tbody> </table> <p>If your primary region doesn’t support it, deploy the proxy in a supported region. The APIGW endpoint is accessible from anywhere.</p> <p>For the full list of CloudWatch service endpoints by region, see the <a href="https://docs.aws.amazon.com/general/latest/gr/cw_region.html">AWS General Reference</a>.</p> <h2 id="gotchas">Gotchas</h2> <p><strong>XRay traces require manual setup.</strong> The CloudFormation template creates the proxy endpoints, but X-Ray traces need two additional steps that aren’t in the template:</p> <ol> <li>Set CloudWatch Logs as the trace segment destination:</li> </ol> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws xray update-trace-segment-destination <span class="nt">--destination</span> CloudWatchLogs
</code></pre></div></div> <ol> <li>Create a CloudWatch Logs resource policy allowing X-Ray to write to the <code class="language-plaintext highlighter-rouge">aws/spans</code> log group:</li> </ol> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws logs put-resource-policy <span class="se">\</span>
  <span class="nt">--policy-name</span> XRayAccessPolicy <span class="se">\</span>
  <span class="nt">--policy-document</span> <span class="s1">'{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"xray.amazonaws.com"},"Action":["logs:PutLogEvents","logs:CreateLogGroup","logs:CreateLogStream"],"Resource":"*"}]}'</span>
</code></pre></div></div> <p>Without these, traces will return <code class="language-plaintext highlighter-rouge">AccessDeniedException</code>.</p> <p><strong>CloudWatch Logs supports bearer token auth.</strong> The <code class="language-plaintext highlighter-rouge">/v1/logs</code> endpoint supports <a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html">bearer token authentication</a> without SigV4 — but only for logs. Metrics and traces still require SigV4, which is why the APIGW proxy is needed for a unified endpoint.</p> <p><strong>Use <code class="language-plaintext highlighter-rouge">http/json</code>, not <code class="language-plaintext highlighter-rouge">http/protobuf</code>.</strong> CloudWatch accepts both formats, but API Gateway’s <code class="language-plaintext highlighter-rouge">CONVERT_TO_TEXT</code> content handling can corrupt binary protobuf payloads in transit. Set <code class="language-plaintext highlighter-rouge">OTEL_EXPORTER_OTLP_PROTOCOL=http/json</code> to avoid this. JSON is also easier to debug in APIGW execution logs. Most coding tools default to protobuf, so you’ll need to override this explicitly.</p> <p><strong>API Gateway payload limit.</strong> REST API has a 10MB payload limit. OTLP batches from coding tools are well under this, but keep it in mind if you’re aggregating from multiple sources. CloudWatch’s own limits are 1MB for metrics and logs, 5MB for traces (<a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html">full limits</a>).</p> <p><strong>REST API, not HTTP API.</strong> Only REST API supports the <code class="language-plaintext highlighter-rouge">AWS</code> integration type needed for SigV4 service proxying. HTTP API does not.</p> <h2 id="cost">Cost</h2> <p>This is about as cheap as it gets for a telemetry pipeline:</p> <table> <thead> <tr> <th>Component</th> <th>Cost</th> </tr> </thead> <tbody> <tr> <td>API Gateway</td> <td>~$3.50 / million requests</td> </tr> <tr> <td>CloudWatch Metrics</td> <td><a href="https://aws.amazon.com/cloudwatch/pricing/">Standard CW pricing</a> (free during OTel metrics preview)</td> </tr> <tr> <td>CloudWatch Logs</td> <td><a href="https://aws.amazon.com/cloudwatch/pricing/">Standard CW pricing</a></td> </tr> <tr> <td>Lambda</td> <td>$0 (there is none)</td> </tr> </tbody> </table> <p>No idle cost. No provisioned capacity. Pure pay-per-request.</p> <p>For comparison: a Lambda-based OTLP forwarder would add ~$0.20/million invocations plus compute time, but gives you retry logic and transformation capabilities. At typical coding agent volumes (a few hundred requests/day per developer), the cost difference is negligible — the real win is operational simplicity.</p> <h2 id="when-not-to-use-this">When NOT to Use This</h2> <p>This proxy is optimized for simplicity. It’s the right choice for low-to-moderate telemetry volumes from coding agents and developer tools. But it has tradeoffs:</p> <table> <thead> <tr> <th>Approach</th> <th>Complexity</th> <th>Cost</th> <th>Retries</th> <th>Multi-destination</th> <th>Transformation</th> </tr> </thead> <tbody> <tr> <td>This proxy (APIGW)</td> <td>Minimal</td> <td>~$3.50/M req</td> <td>❌</td> <td>❌</td> <td>❌</td> </tr> <tr> <td>OTel Collector</td> <td>Medium</td> <td>Compute cost</td> <td>✅</td> <td>✅</td> <td>✅</td> </tr> <tr> <td>Lambda forwarder</td> <td>Medium</td> <td>~$0.20/M + compute</td> <td>✅</td> <td>✅</td> <td>✅</td> </tr> <tr> <td>ADOT SDK (in-app)</td> <td>Low</td> <td>Free (SigV4 native)</td> <td>✅</td> <td>❌</td> <td>❌</td> </tr> <tr> <td>SaaS (Datadog, etc.)</td> <td>Low</td> <td>$$$</td> <td>✅</td> <td>N/A</td> <td>✅</td> </tr> </tbody> </table> <p>Consider an OTel Collector or Lambda forwarder instead if you need:</p> <ul> <li><strong>High throughput</strong> — thousands of requests/second from many sources</li> <li><strong>Retry and buffering</strong> — this proxy is fire-and-forget; if CloudWatch returns an error, the data is lost. OTEL SDKs have built-in retry, but only for transient failures</li> <li><strong>Multi-destination routing</strong> — fan out to CloudWatch + Datadog + S3 simultaneously</li> <li><strong>Payload transformation</strong> — filter, enrich, or redact telemetry before ingestion</li> <li><strong>Compliance requirements</strong> — audit trails, guaranteed delivery, or data residency controls</li> </ul> <p>For most coding agent monitoring use cases (a team of 5-50 developers), this proxy handles the volume comfortably.</p> <h2 id="security-considerations">Security Considerations</h2> <p>The proxy uses API key authentication — simple but not the strongest option. Here’s how to harden it:</p> <p><strong>Attach AWS WAF to the REST API.</strong> Add rate limiting, IP allowlisting, or geo-blocking to prevent abuse. A single WAF WebACL with a rate-based rule (e.g., 1000 req/5min per IP) costs ~$6/month and stops most abuse patterns.</p> <p><strong>Rotate API keys.</strong> APIGW supports multiple API keys per usage plan. Create a new key, distribute it, then disable the old one — zero downtime rotation.</p> <p><strong>Consider IAM auth for internal use.</strong> If your tools run inside AWS (EC2, ECS, Lambda), switch <code class="language-plaintext highlighter-rouge">AuthorizationType</code> from <code class="language-plaintext highlighter-rouge">NONE</code> to <code class="language-plaintext highlighter-rouge">AWS_IAM</code> and drop the API key entirely. The caller signs requests with SigV4 using their IAM role — no shared secrets. This doesn’t work for external tools like Claude Code on developer laptops, but it’s ideal for CI/CD pipelines or server-side agents.</p> <p><strong>Egress control.</strong> If you’re running coding agents in a controlled environment, restrict outbound traffic to only your APIGW endpoint. This prevents telemetry from leaking to unauthorized collectors.</p> <h2 id="beyond-coding-agents">Beyond Coding Agents</h2> <p>This proxy works with <strong>any OTEL SDK</strong> that supports OTLP/HTTP. If your tool can set <code class="language-plaintext highlighter-rouge">OTEL_EXPORTER_OTLP_ENDPOINT</code> and <code class="language-plaintext highlighter-rouge">OTEL_EXPORTER_OTLP_HEADERS</code>, it can ship telemetry to CloudWatch through this proxy.</p> <p>Potential use cases:</p> <ul> <li><strong>AI coding agents</strong> (Claude Code, CoWork, Copilot, Cursor, Gemini CLI) — track token usage, costs, and tool calls across your org</li> <li><strong>Internal tools</strong> — ship metrics without embedding AWS credentials in client apps</li> <li><strong>CI/CD pipelines</strong> — export build/test telemetry to CloudWatch</li> <li><strong>On-premises services</strong> — send OTLP from outside AWS without running ADOT Collector</li> </ul> <p>For apps running inside AWS with IAM roles available, consider the <a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLP-UsingADOT.html">ADOT SDK</a> for collector-less telemetry with native SigV4 signing — no proxy needed.</p> <h2 id="source-code--one-click-deploy">Source Code &amp; One-Click Deploy</h2> <p>The CloudFormation template and full documentation are on GitHub:</p> <p>👉 <a href="https://github.com/gabrielkoo/otlp-cloudwatch-proxy">gabrielkoo/otlp-cloudwatch-proxy</a></p> <p>One-click deploy to supported regions:</p> <table> <thead> <tr> <th>Region</th> <th>Deploy</th> </tr> </thead> <tbody> <tr> <td>US East (N. Virginia)</td> <td><a href="https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/create/review?templateURL=https://raw.githubusercontent.com/gabrielkoo/otlp-cloudwatch-proxy/main/template.yaml&amp;stackName=otlp-cloudwatch-proxy"><img src="/assets/img/4bf452529163.png" alt="Launch Stack"/></a></td> </tr> <tr> <td>US West (Oregon)</td> <td><a href="https://console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https://raw.githubusercontent.com/gabrielkoo/otlp-cloudwatch-proxy/main/template.yaml&amp;stackName=otlp-cloudwatch-proxy"><img src="/assets/img/4bf452529163.png" alt="Launch Stack"/></a></td> </tr> <tr> <td>Asia Pacific (Singapore)</td> <td><a href="https://console.aws.amazon.com/cloudformation/home?region=ap-southeast-1#/stacks/create/review?templateURL=https://raw.githubusercontent.com/gabrielkoo/otlp-cloudwatch-proxy/main/template.yaml&amp;stackName=otlp-cloudwatch-proxy"><img src="/assets/img/4bf452529163.png" alt="Launch Stack"/></a></td> </tr> <tr> <td>Asia Pacific (Sydney)</td> <td><a href="https://console.aws.amazon.com/cloudformation/home?region=ap-southeast-2#/stacks/create/review?templateURL=https://raw.githubusercontent.com/gabrielkoo/otlp-cloudwatch-proxy/main/template.yaml&amp;stackName=otlp-cloudwatch-proxy"><img src="/assets/img/4bf452529163.png" alt="Launch Stack"/></a></td> </tr> <tr> <td>Europe (Ireland)</td> <td><a href="https://console.aws.amazon.com/cloudformation/home?region=eu-west-1#/stacks/create/review?templateURL=https://raw.githubusercontent.com/gabrielkoo/otlp-cloudwatch-proxy/main/template.yaml&amp;stackName=otlp-cloudwatch-proxy"><img src="/assets/img/4bf452529163.png" alt="Launch Stack"/></a></td> </tr> </tbody> </table> <hr/> <p><em>Built and validated on a Saturday morning with Claude Code + OpenClaw. Zero Lambda functions were harmed in the making of this article.</em></p> <h2 id="further-reading">Further Reading</h2> <ul> <li><strong><a href="https://github.com/aws-solutions-library-samples/guidance-for-claude-code-with-amazon-bedrock/blob/main/assets/docs/MONITORING.md">AWS Guidance for Claude Code with Amazon Bedrock — Monitoring</a></strong> — A comprehensive (and admittedly overkill) reference implementation using ECS Fargate + ALB + ADOT Collector + Lambda + DynamoDB + Kinesis + Athena. Great if you want to see the full spectrum of what can be measured: per-user token tracking, quota monitoring, cost dashboards, and an analytics data lake. If you need all of that, use it. If you just need telemetry flowing to CloudWatch, the one-template proxy in this post will do.</li> <li><strong><a href="https://code.claude.com/docs/en/monitoring-usage">Claude Code Monitoring Docs</a></strong> — Official OTEL configuration reference, including all metrics, events, and traces.</li> <li><strong><a href="https://code.claude.com/docs/en/settings#settings-files">Claude Code Managed Settings</a></strong> — How to deploy <code class="language-plaintext highlighter-rouge">managed-settings.json</code> via MDM for org-wide OTEL enforcement.</li> <li><strong><a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html">CloudWatch OTLP Endpoints</a></strong> — AWS docs on native OTLP ingestion for metrics, traces, and logs.</li> </ul>]]></content><author><name>Gabriel Koo (AWS Community Builder)</name><email>hi@gabrielkoo.com</email></author><summary type="html"><![CDATA[I've been building AI tooling infrastructure to empower a team of 50+ software engineers to do vibe...]]></summary></entry><entry><title type="html">Bedrock for AI Coding Tools: Mantle vs Gateway vs LiteLLM — A Decision Guide for AWS Credit Burners</title><link href="https://gabrielkoo.com/blog/bedrock-for-ai-coding-tools-mantle-vs-gateway-vs-litellm-a-decision-guide-for-aws-credit-burners-1h01/" rel="alternate" type="text/html" title="Bedrock for AI Coding Tools: Mantle vs Gateway vs LiteLLM — A Decision Guide for AWS Credit Burners"/><published>2026-03-22T00:00:00+00:00</published><updated>2026-03-22T00:00:00+00:00</updated><id>https://gabrielkoo.com/blog/bedrock-for-ai-coding-tools-mantle-vs-gateway-vs-litellm-a-decision-guide-for-aws-credit-burners-1h01</id><content type="html" xml:base="https://gabrielkoo.com/blog/bedrock-for-ai-coding-tools-mantle-vs-gateway-vs-litellm-a-decision-guide-for-aws-credit-burners-1h01/"><![CDATA[<p>You have AWS credits. You want to use them on AI coding tools — OpenCode, Codex CLI, Claude Code, whatever. Amazon Bedrock has the models. But how do you actually connect them?</p> <p>There are three approaches, and picking the wrong one wastes time. Here’s the decision guide I wish I had.</p> <blockquote> <p>All data in this post is as of March 2026. Model counts and API support may change — check <a href="https://amazonbedrockmodels.github.io">amazonbedrockmodels.github.io</a> for the latest.</p> </blockquote> <h2 id="tldr">TL;DR</h2> <ul> <li><strong>Just want it to work?</strong> Mantle + OpenCode. Five minutes, zero infra.</li> <li><strong>Need Claude models via OpenAI API?</strong> bedrock-access-gateway on Lambda.</li> <li><strong>Need Claude Code specifically?</strong> LiteLLM. It’s the only path.</li> <li><strong>Codex CLI?</strong> Broken with all three. Wait for LiteLLM to fix a tool translation bug.</li> </ul> <h2 id="the-three-paths">The three paths</h2> <p><img src="/assets/img/5ec03a820bf7.png" alt="Decision flowchart: Mantle vs bedrock-access-gateway vs LiteLLM"/></p> <p><strong>One thing all three have in common: your API keys and code context stay within your AWS account or your own infrastructure.</strong> Third-party AI gateways exist (Bifrost, Portkey, etc.), but they require routing your Bedrock API keys and code context through someone else’s servers. Self-hosted or AWS-native — that’s the baseline.</p> <h3 id="1-bedrock-mantle--no-self-hosted-infra-required">1. Bedrock Mantle — no self-hosted infra required</h3> <p><a href="https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html">Mantle</a> is AWS’s native OpenAI-compatible endpoint. No Lambda, no container, no proxy — just set your base URL and API key:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">OPENAI_BASE_URL</span><span class="o">=</span><span class="s2">"https://bedrock-mantle.us-east-1.api.aws/v1"</span>
<span class="nb">export </span><span class="nv">OPENAI_API_KEY</span><span class="o">=</span><span class="s2">"your-bedrock-api-key"</span>
</code></pre></div></div> <p><strong>What’s on Mantle:</strong> 38 open-weight models — DeepSeek, Mistral, Qwen, GLM, NVIDIA Nemotron, MiniMax, Moonshot Kimi, Google Gemma, OpenAI gpt-oss, and Writer Palmyra.</p> <p><strong>What’s NOT on Mantle:</strong> Anthropic Claude, Amazon Nova, Meta Llama, AI21, Cohere — the proprietary/first-party models are absent.</p> <p><strong>API coverage:</strong> Mantle exposes Chat Completions (<code class="language-plaintext highlighter-rouge">/v1/chat/completions</code>) and Responses API (<code class="language-plaintext highlighter-rouge">/v1/responses</code>). No Anthropic Messages API (<code class="language-plaintext highlighter-rouge">/v1/messages</code>).</p> <p>The Responses API is limited — only 4 models support it: <code class="language-plaintext highlighter-rouge">openai.gpt-oss-120b-1:0</code>, <code class="language-plaintext highlighter-rouge">openai.gpt-oss-20b-1:0</code>, <code class="language-plaintext highlighter-rouge">openai.gpt-oss-120b</code>, and <code class="language-plaintext highlighter-rouge">openai.gpt-oss-20b</code>. Every other model is Chat Completions only. I verified this by scraping all 102 model card pages in the <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html">AWS documentation</a>.</p> <p><strong>Cost:</strong> Standard Bedrock on-demand pricing. No gateway markup, no infra costs.</p> <p><strong>Best for:</strong> OpenCode or any tool that speaks OpenAI Chat Completions.</p> <h3 id="2-bedrock-access-gateway--self-hosted-all-models">2. bedrock-access-gateway — self-hosted, all models</h3> <p><a href="https://github.com/aws-samples/bedrock-access-gateway">bedrock-access-gateway</a> (or my fork, <a href="https://github.com/gabrielkoo/bedrock-access-gateway-function-url">bedrock-access-gateway-function-url</a>) gives you an OpenAI-compatible proxy backed by all Bedrock models — including Claude, Nova, and Llama.</p> <p>Deploy it as a Lambda Function URL or on ECS, and you get:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">OPENAI_BASE_URL</span><span class="o">=</span><span class="s2">"https://your-lambda-url.lambda-url.us-west-2.on.aws/api/v1"</span>
<span class="nb">export </span><span class="nv">OPENAI_API_KEY</span><span class="o">=</span><span class="s2">"your-gateway-api-key"</span>
</code></pre></div></div> <p>The tradeoff: you maintain infrastructure. But you get access to every Bedrock model through a single OpenAI-compatible endpoint.</p> <p><strong>Cost:</strong> Bedrock on-demand pricing + Lambda/ECS compute costs (minimal for Lambda Function URLs — you pay per invocation).</p> <p><strong>Best for:</strong> When you need Claude or Nova through OpenAI-compatible tools, or want full control over routing, caching, and logging.</p> <h3 id="3-litellm--the-universal-translator">3. LiteLLM — the universal translator</h3> <p><a href="https://github.com/BerriAI/litellm">LiteLLM</a> is the Swiss Army knife. It translates between API schemas — OpenAI, Anthropic, Bedrock native, and more. It’s the only option that gives you Anthropic Messages API (<code class="language-plaintext highlighter-rouge">/v1/messages</code>) compatibility with Bedrock models.</p> <p>This matters because <strong>Claude Code uses the Anthropic API schema</strong>, not OpenAI’s. If you want to run Claude Code against Bedrock, LiteLLM is your best (and arguably only) option. I tested this end-to-end: Claude Code CLI → LiteLLM → Bedrock Converse API — it works, including streaming responses.</p> <p>Is it perfect? No. Setup is more complex (Python process or Docker container, optional PostgreSQL for analytics), and you’re adding another layer of abstraction. But it’s the most flexible gateway available.</p> <p><strong>Cost:</strong> Bedrock on-demand pricing + your compute costs for hosting LiteLLM. No per-call markup from LiteLLM itself (open source).</p> <p><strong>Best for:</strong> Claude Code, or when you need both OpenAI and Anthropic API compatibility from a single proxy.</p> <h2 id="tool-compatibility-matrix">Tool compatibility matrix</h2> <table> <thead> <tr> <th>Tool</th> <th>API Schema</th> <th>Mantle</th> <th>bedrock-access-gateway</th> <th>LiteLLM</th> </tr> </thead> <tbody> <tr> <td>OpenCode</td> <td>OpenAI Chat</td> <td>✅</td> <td>✅</td> <td>✅</td> </tr> <tr> <td>Codex CLI</td> <td>OpenAI Responses</td> <td>❌ Auth issues</td> <td>❌ No Responses API</td> <td>⚠️ Tool bug</td> </tr> <tr> <td>Claude Code</td> <td>Anthropic Messages</td> <td>❌ No support</td> <td>❌ Wrong schema</td> <td>✅</td> </tr> </tbody> </table> <p>Note: Anthropic-native tools like Kiro CLI also work through LiteLLM’s Anthropic Messages API translation.</p> <h3 id="a-note-on-codex-cli">A note on Codex CLI</h3> <p>Codex CLI requires the Responses API (<code class="language-plaintext highlighter-rouge">/v1/responses</code>), which limits your options:</p> <ul> <li><strong>Mantle:</strong> Only the 4 OpenAI gpt-oss models support Responses API. Even with those, I hit 401 auth errors (Bearer token not passed correctly through Codex’s HTTPS transport) and tool type rejections (<code class="language-plaintext highlighter-rouge">web_search</code> type not supported — only <code class="language-plaintext highlighter-rouge">function</code> and <code class="language-plaintext highlighter-rouge">mcp</code>).</li> <li><strong>bedrock-access-gateway:</strong> No Responses API at all — <code class="language-plaintext highlighter-rouge">/v1/responses</code> returns 404. The gateway only implements Chat Completions.</li> <li><strong>LiteLLM:</strong> Supports Responses API (<a href="https://github.com/BerriAI/litellm/releases">v1.66.3+</a>) and has an <a href="https://docs.litellm.ai/docs/tutorials/openai_codex">official Codex CLI tutorial</a>. However, as of v1.82.5, there’s a tool translation bug: Codex CLI sends built-in tool types that LiteLLM converts to Bedrock Converse format with empty <code class="language-plaintext highlighter-rouge">toolSpec.name</code> fields, causing Bedrock validation errors. The Responses API itself works fine when tested with standard function tools. This should be fixable on the LiteLLM side.</li> </ul> <h2 id="quick-setup-opencode--mantle">Quick setup: OpenCode + Mantle</h2> <p>If you just want to burn AWS credits on a coding CLI today, here’s the fastest path. <a href="https://opencode.ai">OpenCode</a> (v1.2.27+) works with Mantle out of the box:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"$schema"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://opencode.ai/config.json"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"provider"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"bedrock-mantle"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"npm"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@ai-sdk/openai-compatible"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Bedrock Mantle"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"options"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"baseURL"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://bedrock-mantle.us-east-1.api.aws/v1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"apiKey"</span><span class="p">:</span><span class="w"> </span><span class="s2">"{env:BEDROCK_API_KEY}"</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"models"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"openai.gpt-oss-120b"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"GPT OSS 120B"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"zai.glm-5"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"GLM 5 (744B/40B MoE)"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"qwen.qwen3-coder-480b-a35b-instruct"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Qwen3 Coder 480B"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"deepseek.v3.2"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"DeepSeek V3.2"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="nl">"mistral.mistral-large-3-675b-instruct"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Mistral Large 3"</span><span class="w"> </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"bedrock-mantle/openai.gpt-oss-120b"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p>Save to <code class="language-plaintext highlighter-rouge">~/.config/opencode/opencode.json</code>, set <code class="language-plaintext highlighter-rouge">BEDROCK_API_KEY</code>, and you’re coding.</p> <h2 id="the-bottom-line">The bottom line</h2> <table> <thead> <tr> <th> </th> <th>Mantle</th> <th>bedrock-access-gateway</th> <th>LiteLLM</th> </tr> </thead> <tbody> <tr> <td>Infra to maintain</td> <td>None</td> <td>Lambda/ECS</td> <td>Container/process</td> </tr> <tr> <td>Models available</td> <td>38 (open-weight)</td> <td>All Bedrock</td> <td>All Bedrock</td> </tr> <tr> <td>OpenAI Chat API</td> <td>✅</td> <td>✅</td> <td>✅</td> </tr> <tr> <td>OpenAI Responses API</td> <td>⚠️ gpt-oss only</td> <td>❌</td> <td>✅</td> </tr> <tr> <td>Anthropic Messages API</td> <td>❌</td> <td>❌</td> <td>✅</td> </tr> <tr> <td>OpenCode</td> <td>✅</td> <td>✅</td> <td>✅</td> </tr> <tr> <td>Codex CLI</td> <td>❌</td> <td>❌</td> <td>⚠️ Tool bug</td> </tr> <tr> <td>Claude Code</td> <td>❌</td> <td>❌</td> <td>✅</td> </tr> <tr> <td>Extra cost</td> <td>None</td> <td>~$0 (Lambda)</td> <td>Your compute</td> </tr> <tr> <td>Setup time</td> <td>5 min</td> <td>30 min</td> <td>1 hr</td> </tr> </tbody> </table> <h2 id="track-available-mantle-models">Track available Mantle models</h2> <p>I maintain <a href="https://amazonbedrockmodels.github.io">amazonbedrockmodels.github.io</a> — a catalog of every Bedrock model with API support badges and endpoint support (Mantle vs Runtime), scraped from the AWS documentation.</p> <hr/> <p><em>Burning AWS credits on something interesting? I’d love to hear what tools and models you’re using — drop a comment.</em> comment.*</p>]]></content><author><name>Gabriel Koo (AWS Community Builder)</name><email>hi@gabrielkoo.com</email></author><summary type="html"><![CDATA[You have AWS credits. You want to use them on AI coding tools — OpenCode, Codex CLI, Claude Code,...]]></summary></entry><entry><title type="html">From 3-Minute Cold Starts to ~20 Seconds: Whisper on AWS Lambda + EFS for OpenClaw</title><link href="https://gabrielkoo.com/blog/from-3-minute-cold-starts-to-20-seconds-whisper-on-aws-lambda-efs-for-openclaw-9c5/" rel="alternate" type="text/html" title="From 3-Minute Cold Starts to ~20 Seconds: Whisper on AWS Lambda + EFS for OpenClaw"/><published>2026-03-13T00:00:00+00:00</published><updated>2026-03-13T00:00:00+00:00</updated><id>https://gabrielkoo.com/blog/from-3-minute-cold-starts-to-20-seconds-whisper-on-aws-lambda-efs-for-openclaw-9c5</id><content type="html" xml:base="https://gabrielkoo.com/blog/from-3-minute-cold-starts-to-20-seconds-whisper-on-aws-lambda-efs-for-openclaw-9c5/"><![CDATA[<p><em>Part 3 of my series on building a low-cost personal AI stack on AWS.</em> <em><a href="https://dev.to/aws-builders/i-squeezed-my-1k-monthly-openclaw-api-bill-with-20month-in-aws-credits-heres-the-exact-setup-3gj4">Part 1 — Squeezing my $1k/month API bill to $20/month with AWS Credits</a></em> <em><a href="https://dev.to/aws-builders/drop-in-perplexity-sonar-replacement-with-aws-bedrock-nova-grounding-35o9">Part 2 — Drop-in Perplexity Sonar replacement with AWS Bedrock Nova Grounding</a></em></p> <hr/> <h2 id="tldr">TL;DR</h2> <p>I built a self-hosted speech-to-text API on AWS Lambda using <a href="https://github.com/SYSTRAN/faster-whisper">faster-whisper</a>. After trying Amazon Transcribe, SageMaker Serverless, and Lambda with a bundled model, I landed on a <strong>Lambda + EFS + S3</strong> architecture that achieves ~20-30 second cold starts (once the model is cached on EFS) for ~$0.21/month in storage costs. Once warm, specifying the language drops response time to ~10s.</p> <p>Open source: <a href="https://github.com/gabrielkoo/aws-lambda-whisper-adaptor">gabrielkoo/aws-lambda-whisper-adaptor</a></p> <hr/> <h2 id="the-problem">The Problem</h2> <p>I wanted to automatically transcribe Telegram voice messages. The requirements were simple:</p> <ul> <li><strong>Accuracy</strong>: Good enough for Cantonese</li> <li><strong>Cost</strong>: Pay-per-use, scales to zero when idle</li> <li><strong>Latency</strong>: Cold start under 60 seconds</li> </ul> <p>There’s a fourth constraint that’s easy to overlook outside Hong Kong: <strong>most managed STT APIs simply aren’t available here</strong>. OpenAI’s Whisper API falls under their notorious China’s regional restriction. Google’s Gemini models are available and actually competitive on both accuracy and price — Gemini 3 Flash achieves 3.1% WER at ~$1.92/1000 minutes (<a href="https://artificialanalysis.ai/speech-to-text">Artificial Analysis STT leaderboard</a>), cheaper than OpenAI’s Whisper API and competitive with Lambda at low volume. The real reason I went with Lambda: AWS Credits from the Community Builder program (same theme as the rest of this series) make it effectively free.</p> <p>Simple enough. Except it took four attempts to get there.</p> <hr/> <h2 id="what-i-tried-and-why-it-didnt-work">What I Tried (and Why It Didn’t Work)</h2> <h3 id="option-1-amazon-transcribe">Option 1: Amazon Transcribe</h3> <p>The obvious first choice — fully managed, pay-per-use, native AWS integration.</p> <p><strong>Why I rejected it before even trying:</strong></p> <p>Amazon Transcribe <a href="https://docs.aws.amazon.com/transcribe/latest/dg/supported-languages.html">supports <code class="language-plaintext highlighter-rouge">zh-CN</code> and <code class="language-plaintext highlighter-rouge">zh-TW</code>, but not <code class="language-plaintext highlighter-rouge">yue</code> (Cantonese)</a>. Whisper large-v3-turbo handles Cantonese significantly better, and accuracy matters more than convenience here.</p> <hr/> <h3 id="option-2-sagemaker-serverless-inference">Option 2: SageMaker Serverless Inference</h3> <p>SageMaker Serverless scales to zero and handles model serving — sounds perfect.</p> <p><strong>What happened:</strong></p> <p>I deployed a SageMaker Serverless endpoint with faster-whisper. The first invocation after idle:</p> <ul> <li>Container provisioning: ~30s</li> <li>Model loading: ~45-60s</li> <li><strong>Total cold start: 60-90 seconds</strong></li> </ul> <p>For a voice message that’s 5-10 seconds long, waiting 90 seconds is a terrible experience.</p> <p><strong>The 6GB memory wall:</strong></p> <p>SageMaker Serverless <a href="https://docs.aws.amazon.com/sagemaker/latest/dg/serverless-endpoints.html">maxes out at 6144 MB (6 GB) RAM</a>. Here’s why that’s a problem for Whisper:</p> <ul> <li><a href="https://huggingface.co/Zoont/faster-whisper-large-v3-turbo-int8-ct2"><code class="language-plaintext highlighter-rouge">whisper-large-v3-turbo</code> (INT8)</a>: ~780MB model + ~2GB Python/runtime overhead ≈ 2.8GB minimum</li> <li><a href="https://huggingface.co/Systran/faster-whisper-large-v3"><code class="language-plaintext highlighter-rouge">whisper-large-v3</code> (FP16)</a>: ~3GB model alone — barely fits, zero headroom for audio processing</li> <li>Any concurrent requests? You’re OOM.</li> </ul> <p><a href="https://docs.aws.amazon.com/lambda/latest/dg/configuration-memory.html">Lambda goes up to 10,240 MB</a>. That headroom matters.</p> <p><strong>Cost comparison:</strong></p> <p><a href="https://aws.amazon.com/sagemaker/pricing/">SageMaker Serverless bills per GB-second</a> of inference time. For sporadic voice message transcription (~10s per request, a few times a day), Lambda’s per-invocation pricing is significantly cheaper. My Lambda setup costs ~$0.21/month in storage — the compute is essentially free at this volume.</p> <p>I deleted the endpoint after testing.</p> <hr/> <h3 id="option-2b-bedrock-marketplace">Option 2b: Bedrock Marketplace</h3> <p>AWS Bedrock Marketplace <a href="https://aws.amazon.com/blogs/machine-learning/build-a-serverless-audio-summarization-solution-with-amazon-bedrock-and-whisper/">does list Whisper Large V3 Turbo</a> — but it deploys on a <strong>dedicated endpoint instance</strong>. Auto-scaling is available (including scale-to-zero), but that creates a different problem:</p> <ul> <li><strong>Keep minimum 1 instance</strong>: always paying for idle time, even at 3am</li> <li><strong>Scale to zero</strong>: cold starts when traffic resumes — SageMaker cold starts are measured in <strong>minutes</strong>, not seconds</li> <li>Not token/usage-based pricing either way</li> </ul> <p>For a Telegram bot that gets a few voice messages a day, you’re either burning money on idle instances or waiting minutes for the first message to transcribe. Lambda’s ~20-30s cold start looks great by comparison.</p> <hr/> <h3 id="option-3-lambda-with-bundled-model">Option 3: Lambda with Bundled Model</h3> <p>Next idea: bundle the model directly into the Docker image. No external dependencies, simple architecture.</p> <p><strong>What happened:</strong></p> <div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Download model during build</span>
<span class="c"># Note: using openai/whisper-large-v3-turbo converted to int8 via sync-model workflow</span>
<span class="k">RUN </span>python <span class="nt">-c</span> <span class="s2">"from faster_whisper import WhisperModel; WhisperModel('openai/whisper-large-v3-turbo')"</span>
</code></pre></div></div> <ul> <li>Docker image size: <strong>~10GB</strong></li> <li>ECR push time: <strong>5+ minutes</strong></li> <li>Lambda cold start: <strong>2 minutes 51 seconds</strong></li> </ul> <p>The cold start is dominated by Lambda pulling the 10GB image from ECR. AWS Lambda caches images, but any cold start after the cache expires hits this wall.</p> <p><strong>Why it didn’t work:</strong></p> <ul> <li>3-minute cold start is unusable for interactive transcription</li> <li>Every code change requires rebuilding and pushing a 10GB image</li> <li>ECR storage: ~$1/month just for the image</li> </ul> <hr/> <h3 id="option-4-lambda--s3-no-efs">Option 4: Lambda + S3 (No EFS)</h3> <p>What if Lambda downloads the model from S3 on cold start, storing it in <code class="language-plaintext highlighter-rouge">/tmp</code>?</p> <p><strong>The problem:</strong></p> <p>Lambda’s <code class="language-plaintext highlighter-rouge">/tmp</code> is ephemeral. Every cold start re-downloads the model from S3:</p> <ul> <li>S3 download for 1.6GB FP16 model: <strong>30-60 seconds</strong></li> <li>S3 download for 780MB INT8 model: <strong>15-30 seconds</strong></li> </ul> <p>This is better than the bundled model approach, but there’s a bigger issue: <strong>no caching between Lambda instances</strong>. If you have 3 concurrent invocations, all 3 download the model independently. You’re paying for S3 transfer on every cold start.</p> <p><strong>What about Lambda SnapStart or Durable Functions?</strong></p> <p>AWS added two relevant features since this was written:</p> <ul> <li> <p><strong>SnapStart for Python</strong> (Nov 2024): snapshots the initialized execution environment — sounds perfect for caching a loaded model. The catch: <a href="https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html">SnapStart doesn’t support container images</a>. This adaptor is container-based, so it’s off the table.</p> </li> <li> <p><strong><a href="https://aws.amazon.com/about-aws/whats-new/2025/12/lambda-durable-multi-step-applications-ai-workflows/">Lambda Durable Functions</a></strong> (re:Invent 2025): enables multi-step workflows with automatic checkpointing, pause/resume for up to one year, and failure recovery. This is workflow orchestration (think Azure Durable Functions) — useful for multi-step AI pipelines, but not for persisting a 780MB model binary between cold starts.</p> </li> </ul> <p>EFS remains the right solution for model caching.</p> <hr/> <h2 id="what-actually-worked-lambda--efs--s3">What Actually Worked: Lambda + EFS + S3</h2> <p>The solution: use <strong>EFS as a persistent model cache</strong>, bootstrapped from S3. I’ve used EFS for <a href="https://dev.to/aws-builders/scale-a-stateful-streamlit-chatbot-with-aws-ecs-and-efs-48gm">persistent Streamlit state on ECS</a> before — same pattern, different compute layer.</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Request → Lambda Function URL
               ↓
          Lambda (VPC)
               ↓ first cold start only: S3 → EFS
              EFS (model cached here permanently)
</code></pre></div></div> <p><img src="/assets/img/57f530f3907a.png" alt="Logic Flow"/></p> <p><strong>How it works:</strong></p> <ol> <li><strong>First cold start</strong> (once per model): Lambda checks for a marker file on EFS. If missing, downloads model from S3 to EFS (~55s for INT8). Writes marker file. Then loads model into RAM.</li> <li><strong>Subsequent cold starts</strong> (new container, model already on EFS): Marker file exists → load model from EFS into RAM (~20-30s for INT8).</li> <li><strong>Warm invocations</strong> (same container reused): Model already in memory → transcription-only time (~10-22s depending on audio length and whether language is specified).</li> </ol> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">HF_MODEL_REPO</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">HF_MODEL_REPO</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">openai/whisper-large-v3-turbo</span><span class="sh">'</span><span class="p">)</span>
<span class="n">MODEL_SLUG</span> <span class="o">=</span> <span class="n">HF_MODEL_REPO</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sh">'</span><span class="s">/</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">--</span><span class="sh">'</span><span class="p">)</span>
<span class="n">EFS_MODEL_DIR</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">'</span><span class="s">/mnt/whisper-models/</span><span class="si">{</span><span class="n">MODEL_SLUG</span><span class="si">}</span><span class="sh">'</span>
<span class="n">MODEL_MARKER</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">'</span><span class="s">/mnt/whisper-models/.ready-</span><span class="si">{</span><span class="n">MODEL_SLUG</span><span class="si">}</span><span class="sh">'</span>

<span class="k">def</span> <span class="nf">bootstrap_model</span><span class="p">():</span>
    <span class="k">if</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">exists</span><span class="p">(</span><span class="n">MODEL_MARKER</span><span class="p">):</span>
        <span class="k">return</span> <span class="nc">WhisperModel</span><span class="p">(</span><span class="n">EFS_MODEL_DIR</span><span class="p">,</span> <span class="n">device</span><span class="o">=</span><span class="sh">'</span><span class="s">cpu</span><span class="sh">'</span><span class="p">,</span> <span class="n">compute_type</span><span class="o">=</span><span class="sh">'</span><span class="s">int8</span><span class="sh">'</span><span class="p">)</span>
    
    <span class="c1"># First run: sync model from S3 to EFS
</span>    <span class="n">s3</span> <span class="o">=</span> <span class="n">boto3</span><span class="p">.</span><span class="nf">client</span><span class="p">(</span><span class="sh">'</span><span class="s">s3</span><span class="sh">'</span><span class="p">)</span>
    <span class="n">prefix</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">'</span><span class="s">models/</span><span class="si">{</span><span class="n">MODEL_SLUG</span><span class="si">}</span><span class="s">/</span><span class="sh">'</span>
    <span class="n">os</span><span class="p">.</span><span class="nf">makedirs</span><span class="p">(</span><span class="n">EFS_MODEL_DIR</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    
    <span class="n">paginator</span> <span class="o">=</span> <span class="n">s3</span><span class="p">.</span><span class="nf">get_paginator</span><span class="p">(</span><span class="sh">'</span><span class="s">list_objects_v2</span><span class="sh">'</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">page</span> <span class="ow">in</span> <span class="n">paginator</span><span class="p">.</span><span class="nf">paginate</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="sh">'</span><span class="s">MODEL_S3_BUCKET</span><span class="sh">'</span><span class="p">],</span> <span class="n">Prefix</span><span class="o">=</span><span class="n">prefix</span><span class="p">):</span>
        <span class="k">for</span> <span class="n">obj</span> <span class="ow">in</span> <span class="n">page</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">Contents</span><span class="sh">'</span><span class="p">,</span> <span class="p">[]):</span>
            <span class="n">key</span> <span class="o">=</span> <span class="n">obj</span><span class="p">[</span><span class="sh">'</span><span class="s">Key</span><span class="sh">'</span><span class="p">]</span>
            <span class="n">local_path</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">EFS_MODEL_DIR</span><span class="p">,</span> <span class="n">key</span><span class="p">[</span><span class="nf">len</span><span class="p">(</span><span class="n">prefix</span><span class="p">):])</span>
            <span class="n">os</span><span class="p">.</span><span class="nf">makedirs</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">dirname</span><span class="p">(</span><span class="n">local_path</span><span class="p">),</span> <span class="n">exist_ok</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
            <span class="n">s3</span><span class="p">.</span><span class="nf">download_file</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="sh">'</span><span class="s">MODEL_S3_BUCKET</span><span class="sh">'</span><span class="p">],</span> <span class="n">key</span><span class="p">,</span> <span class="n">local_path</span><span class="p">)</span>
    
    <span class="nf">open</span><span class="p">(</span><span class="n">MODEL_MARKER</span><span class="p">,</span> <span class="sh">'</span><span class="s">w</span><span class="sh">'</span><span class="p">).</span><span class="nf">close</span><span class="p">()</span>  <span class="c1"># Mark as ready
</span>    <span class="k">return</span> <span class="nc">WhisperModel</span><span class="p">(</span><span class="n">EFS_MODEL_DIR</span><span class="p">,</span> <span class="n">device</span><span class="o">=</span><span class="sh">'</span><span class="s">cpu</span><span class="sh">'</span><span class="p">,</span> <span class="n">compute_type</span><span class="o">=</span><span class="sh">'</span><span class="s">int8</span><span class="sh">'</span><span class="p">)</span>

<span class="n">MODEL</span> <span class="o">=</span> <span class="nf">bootstrap_model</span><span class="p">()</span>  <span class="c1"># Runs at Lambda init time, cached for warm invocations
</span></code></pre></div></div> <p><strong>Why EFS works:</strong></p> <ul> <li>EFS persists across Lambda instances — model is downloaded <strong>once</strong>, reused forever</li> <li>EFS is mounted at <code class="language-plaintext highlighter-rouge">/mnt/whisper-models</code> — Lambda reads it like a local filesystem</li> <li><strong>S3 VPC Gateway Endpoint is free</strong> — no NAT Gateway needed (saves ~$32/month)</li> <li><strong>Zero internet egress</strong> — Lambda → S3 via VPC Gateway Endpoint, Lambda → EFS within VPC. The Lambda function never reaches the internet. This is a meaningful security benefit when using third-party models from HuggingFace — model weights never leave the AWS network once synced to S3.</li> <li>EFS storage: ~$0.19/month for the 780MB INT8 model</li> </ul> <blockquote> <p>🔒 <strong>Security note:</strong> The Lambda runs in a VPC with <strong>no internet access</strong> — no NAT Gateway, no public subnet. It can only reach EFS (VPC-internal) and S3 (via the free VPC Gateway Endpoint). This means even if you’re using a third-party HuggingFace model, the model weights and your audio data never leave the AWS network. No data exfiltration risk, no outbound calls to unknown endpoints.</p> </blockquote> <hr/> <h2 id="int8-vs-fp16-the-model-size-trade-off">INT8 vs FP16: The Model Size Trade-off</h2> <p>The <code class="language-plaintext highlighter-rouge">openai/whisper-large-v3-turbo</code> model on HuggingFace needs conversion to CTranslate2 format. The <code class="language-plaintext highlighter-rouge">sync-model</code> workflow handles this, converting to INT8 and fixing the <code class="language-plaintext highlighter-rouge">num_mel_bins</code> config. Alternatively, use <a href="https://huggingface.co/Zoont/faster-whisper-large-v3-turbo-int8-ct2"><code class="language-plaintext highlighter-rouge">Zoont/faster-whisper-large-v3-turbo-int8-ct2</code></a> — a pre-converted CTranslate2 INT8 model that works out of the box with <code class="language-plaintext highlighter-rouge">quantization=none</code>:</p> <table> <thead> <tr> <th>Model</th> <th>Size (EFS)</th> <th>First Bootstrap</th> <th>EFS Cold Start</th> <th>Warm (2.5s audio)</th> <th>Memory</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">Zoont/faster-whisper-large-v3-turbo-int8-ct2</code></td> <td>~780MB</td> <td>~55s</td> <td><strong>~22s</strong> ✅</td> <td><strong>~10s</strong> ✅</td> <td>~2.8GB</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">openai/whisper-large-v3-turbo</code> (INT8, via sync-model)</td> <td>~780MB</td> <td>~55s</td> <td>~22s</td> <td>~10s</td> <td>~2.8GB</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">openai/whisper-large-v3-turbo</code> (FP16)</td> <td>~1.5GB</td> <td>~126s</td> <td>~40s</td> <td>~15s</td> <td>~4GB</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">Systran/faster-whisper-large-v3</code> (FP16, loaded as int8)</td> <td>~1.6GB</td> <td>~54s</td> <td>~30s</td> <td>~13s</td> <td>6GB</td> </tr> </tbody> </table> <p><strong>Recommended:</strong> <code class="language-plaintext highlighter-rouge">Zoont/faster-whisper-large-v3-turbo-int8-ct2</code> — no conversion step needed, identical performance to the openai model converted to INT8. Use <code class="language-plaintext highlighter-rouge">quantization=none</code> in the sync-model workflow since it’s already in CTranslate2 format.</p> <hr/> <h2 id="cost-breakdown">Cost Breakdown</h2> <table> <thead> <tr> <th>Resource</th> <th>Monthly Cost</th> </tr> </thead> <tbody> <tr> <td>EFS storage (780MB INT8)</td> <td>~$0.19</td> </tr> <tr> <td>S3 storage (780MB)</td> <td>~$0.02</td> </tr> <tr> <td>Lambda compute</td> <td>~$0.00167/warm invocation*</td> </tr> <tr> <td>S3 VPC Gateway Endpoint</td> <td><strong>Free</strong></td> </tr> <tr> <td>NAT Gateway</td> <td><strong>Not needed ($0)</strong></td> </tr> <tr> <td><strong>Total (storage only)</strong></td> <td><strong>~$0.21/month</strong></td> </tr> </tbody> </table> <p>*10GB × 10s = 100 GB-seconds per warm invocation. The <a href="https://aws.amazon.com/lambda/pricing/">Lambda free tier</a> covers <strong>400,000 GB-seconds/month</strong> — roughly 4,000 warm invocations. For a personal bot, compute cost is effectively <strong>$0</strong>. Storage dominates.</p> <p>Compare to SageMaker Serverless: minimum ~$5-10/month for similar workloads, plus the 60-90s cold start penalty.</p> <blockquote> <p><strong>Why not Provisioned Concurrency?</strong> PC keeps Lambda permanently warm (no cold starts), but costs ~$0.0000097222/GB-second. For a 10GB function running 24/7: ~$252/month. Even a minimal 4GB setup runs ~$100/month — roughly 500x more than the $0.21 storage approach. For a personal bot with a few voice messages a day, the occasional ~60s cold start is a fine trade-off.</p> </blockquote> <h3 id="vs-openai-whisper-api">vs. OpenAI Whisper API</h3> <p>OpenAI’s Whisper API costs <a href="https://openai.com/api/pricing/"><strong>$0.006/minute</strong></a>. Here’s how it compares for a bot averaging 15s voice messages:</p> <table> <thead> <tr> <th>Volume</th> <th>OpenAI Whisper API</th> <th>Self-hosted Lambda</th> </tr> </thead> <tbody> <tr> <td>50 msgs/month</td> <td>$0.08</td> <td>$0.21 (storage only)</td> </tr> <tr> <td>140 msgs/month</td> <td>$0.21</td> <td><strong>$0.21</strong> ← break-even</td> </tr> <tr> <td>500 msgs/month</td> <td>$0.75</td> <td>$0.21 (storage only)</td> </tr> <tr> <td>1,000 msgs/month</td> <td>$1.50</td> <td>$0.21 (storage only)</td> </tr> <tr> <td>4,000 msgs/month</td> <td>$6.00</td> <td>$0.21 (storage only)</td> </tr> </tbody> </table> <p>Lambda compute is free within the free tier (~4,000 warm invocations/month). Beyond that, it’s $0.00167/invocation — but that’s a high volume for a personal bot.</p> <p>Break-even: <strong>~140 messages/month</strong>. Above that, Lambda wins on cost.</p> <p>But cost isn’t the only reason to self-host:</p> <ul> <li><strong>Geographic availability</strong>: OpenAI’s API is not available in Hong Kong — HK falls under China’s regional restriction. Azure OpenAI does offer Whisper, but <a href="https://learn.microsoft.com/en-us/answers/questions/2237575/new-announced-speech-to-text-models-for-realtime">only <code class="language-plaintext highlighter-rouge">whisper-1</code> (large-v2 based)</a> — large-v3 and large-v3-turbo are not available. If you’re in HK (or other restricted regions), this approach isn’t just cheaper — it’s the only option for v3-quality transcription.</li> <li><strong>Cantonese accuracy</strong>: <code class="language-plaintext highlighter-rouge">language=yue</code> with Whisper large-v3-turbo is noticeably better than the managed API for Cantonese</li> <li><strong>Privacy</strong>: audio never leaves your infrastructure</li> <li><strong>No rate limits</strong>: Lambda scales independently</li> </ul> <hr/> <h2 id="architecture">Architecture</h2> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Telegram voice message
        ↓
   OpenClaw (gateway)
        ↓
Lambda Function URL (auth via token)
        ↓
Lambda (VPC, 10GB RAM, 900s timeout)
        ↓
EFS /mnt/whisper-models/{model-slug}
        ↓
faster-whisper (CTranslate2, INT8)
        ↓
    Transcript
</code></pre></div></div> <p><strong>Lambda configuration:</strong></p> <ul> <li>Memory: 10,240 MB — actual usage is <strong>~2.2GB</strong> (INT8 model), but <a href="https://docs.aws.amazon.com/lambda/latest/dg/configuration-memory.html">Lambda allocates CPU proportional to memory</a>. 10GB gives ~6 vCPUs vs ~2.3 vCPUs at 4GB, cutting warm transcription from ~16s to ~10s. You’re paying for CPU, not RAM.</li> <li>Timeout: 900s (handles long audio files)</li> <li>VPC: Default VPC (no NAT Gateway)</li> <li>EFS: Mounted at <code class="language-plaintext highlighter-rouge">/mnt/whisper-models</code></li> </ul> <p><strong>Memory vs. cost trade-off (tested, 3 runs each):</strong></p> <table> <thead> <tr> <th>Config</th> <th>Cold Start</th> <th>Warm (2.5s audio)</th> <th>GB-seconds/invocation</th> </tr> </thead> <tbody> <tr> <td>4,096 MB</td> <td>~30s</td> <td>~21s</td> <td>84 (~$0.00140)</td> </tr> <tr> <td>6,144 MB</td> <td>~25s</td> <td>~16s</td> <td>96 (~$0.00160)</td> </tr> <tr> <td>8,192 MB</td> <td>~24s</td> <td>~18s</td> <td>144 (~$0.00240)</td> </tr> <tr> <td>10,240 MB</td> <td>~22s</td> <td>~15s</td> <td>150 (~$0.00250)</td> </tr> </tbody> </table> <p>Cold start is ~20-30s across all configs — it’s EFS I/O bound, not CPU bound, so more memory doesn’t help much here. Warm inference time does scale with memory (more vCPUs = faster CTranslate2 decoding). Interestingly, 4GB is the cheapest per invocation — the warm time savings at higher memory don’t offset the extra GB-seconds. Within the free tier, cost differences are negligible regardless.</p> <hr/> <h2 id="api-compatibility">API Compatibility</h2> <p>The adaptor exposes two endpoints so it works as a drop-in replacement for existing integrations:</p> <p><strong>OpenAI compatible</strong> (<code class="language-plaintext highlighter-rouge">/v1/audio/transcriptions</code>):</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST https://&lt;<span class="k">function</span><span class="nt">-url</span><span class="o">&gt;</span>/v1/audio/transcriptions <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Token &lt;secret&gt;"</span> <span class="se">\</span>
  <span class="nt">-F</span> <span class="s2">"file=@audio.ogg"</span> <span class="se">\</span>
  <span class="nt">-F</span> <span class="s2">"language=yue"</span>
</code></pre></div></div> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"transcript here"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p><strong>Deepgram compatible</strong> (<code class="language-plaintext highlighter-rouge">/v1/listen</code>):</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST https://&lt;<span class="k">function</span><span class="nt">-url</span><span class="o">&gt;</span>/v1/listen?language<span class="o">=</span>yue <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Token &lt;secret&gt;"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: audio/ogg"</span> <span class="se">\</span>
  <span class="nt">--data-binary</span> @audio.ogg
</code></pre></div></div> <hr/> <h2 id="model-management-api">Model Management API</h2> <p>Once you’ve synced multiple models to EFS, there’s no SSH access to see what’s there or clean up. I added two non-standard endpoints:</p> <p><strong>List models on EFS:</strong></p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl https://&lt;<span class="k">function</span><span class="nt">-url</span><span class="o">&gt;</span>/v1/models <span class="nt">-H</span> <span class="s2">"Authorization: Token &lt;secret&gt;"</span>
</code></pre></div></div> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"object"</span><span class="p">:</span><span class="w"> </span><span class="s2">"list"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"data"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"openai/whisper-large-v3-turbo"</span><span class="p">,</span><span class="w"> </span><span class="nl">"object"</span><span class="p">:</span><span class="w"> </span><span class="s2">"model"</span><span class="p">,</span><span class="w"> </span><span class="nl">"owned_by"</span><span class="p">:</span><span class="w"> </span><span class="s2">"openai"</span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Systran/faster-distil-whisper-large-v3"</span><span class="p">,</span><span class="w"> </span><span class="nl">"object"</span><span class="p">:</span><span class="w"> </span><span class="s2">"model"</span><span class="p">,</span><span class="w"> </span><span class="nl">"owned_by"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Systran"</span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p><strong>Delete a model from EFS</strong> (the currently loaded model returns 409):</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> DELETE https://&lt;<span class="k">function</span><span class="nt">-url</span><span class="o">&gt;</span>/v1/models/Systran/faster-distil-whisper-large-v3 <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Token &lt;secret&gt;"</span>
</code></pre></div></div> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Systran/faster-distil-whisper-large-v3"</span><span class="p">,</span><span class="w"> </span><span class="nl">"object"</span><span class="p">:</span><span class="w"> </span><span class="s2">"model"</span><span class="p">,</span><span class="w"> </span><span class="nl">"deleted"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p>Slashes in model IDs work naturally — <code class="language-plaintext highlighter-rouge">rawPath</code> preserves the full path, so <code class="language-plaintext highlighter-rouge">DELETE /v1/models/openai/whisper-large-v3-turbo</code> correctly maps to model ID <code class="language-plaintext highlighter-rouge">openai/whisper-large-v3-turbo</code>.</p> <hr/> <h2 id="performance-tip-always-specify-language">Performance Tip: Always Specify Language</h2> <p>When no language is specified, Whisper runs language detection on the first audio chunk — adding noticeable overhead. For a 2.5s voice message:</p> <table> <thead> <tr> <th>Request</th> <th>Response Time</th> </tr> </thead> <tbody> <tr> <td>No language (auto-detect)</td> <td>~22s</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">language=yue</code> (Cantonese)</td> <td>~10s</td> </tr> </tbody> </table> <p>That’s a <strong>2x speedup</strong> just from passing a language hint. Two ways to do it:</p> <p><strong>Option A — per-request query param</strong> (recommended, keeps Lambda language-agnostic):</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Deepgram endpoint</span>
curl <span class="nt">-X</span> POST https://&lt;<span class="k">function</span><span class="nt">-url</span><span class="o">&gt;</span>/v1/listen?language<span class="o">=</span>yue <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Token &lt;secret&gt;"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: audio/ogg"</span> <span class="se">\</span>
  <span class="nt">--data-binary</span> @audio.ogg

<span class="c"># OpenAI endpoint</span>
curl <span class="nt">-X</span> POST https://&lt;<span class="k">function</span><span class="nt">-url</span><span class="o">&gt;</span>/v1/audio/transcriptions <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Token &lt;secret&gt;"</span> <span class="se">\</span>
  <span class="nt">-F</span> <span class="s2">"file=@audio.ogg"</span> <span class="se">\</span>
  <span class="nt">-F</span> <span class="s2">"language=yue"</span>
</code></pre></div></div> <p><strong>Option B — Lambda env var</strong> (simpler if you only ever transcribe one language):</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">WHISPER_LANGUAGE</span><span class="o">=</span>yue
</code></pre></div></div> <p>I use Option A — the language is set in my OpenClaw config (<code class="language-plaintext highlighter-rouge">language: "yue"</code> in the audio model), which passes it as <code class="language-plaintext highlighter-rouge">?language=yue</code> to the Lambda on every request.</p> <h3 id="real-time-factor">Real-time Factor</h3> <p>Once warm, the Lambda transcribes faster than real-time for typical voice messages:</p> <table> <thead> <tr> <th>Audio Duration</th> <th>Warm Response Time</th> <th>Real-time Factor</th> </tr> </thead> <tbody> <tr> <td>2.5s</td> <td>~10s</td> <td>4x</td> </tr> <tr> <td>33s</td> <td>~23s</td> <td><strong>0.68x</strong> ✅ faster than real-time</td> </tr> </tbody> </table> <p>The 2.5s result looks slow (4x), but Whisper processes audio in 30-second chunks — the overhead is fixed regardless of audio length. For longer messages, the real-time factor drops well below 1x.</p> <hr/> <h2 id="open-source">Open Source</h2> <p>The project is open source at <a href="https://github.com/gabrielkoo/aws-lambda-whisper-adaptor">gabrielkoo/aws-lambda-whisper-adaptor</a>.</p> <p>Key features:</p> <ul> <li>Any <a href="https://huggingface.co/models?search=faster-whisper">faster-whisper</a> model via <code class="language-plaintext highlighter-rouge">HF_MODEL_REPO</code> env var</li> <li>GitHub Actions workflow to sync models from HuggingFace → S3 (<code class="language-plaintext highlighter-rouge">quantization=int8</code> for HF-format models, <code class="language-plaintext highlighter-rouge">quantization=none</code> for pre-converted CTranslate2 models)</li> <li><code class="language-plaintext highlighter-rouge">GET /v1/models</code> — list all models currently on EFS</li> <li><code class="language-plaintext highlighter-rouge">DELETE /v1/models/{owner}/{model}</code> — remove a model from EFS on demand</li> <li>Pre-built Docker image: <code class="language-plaintext highlighter-rouge">ghcr.io/gabrielkoo/aws-lambda-whisper-adaptor:latest</code></li> <li>Configurable language detection via <code class="language-plaintext highlighter-rouge">WHISPER_LANGUAGE</code> env var or per-request parameter</li> </ul> <hr/> <h2 id="pre-warming">Pre-warming</h2> <blockquote> <p><strong>For OpenClaw voice prompts:</strong> the ~20-30s cold start is often negligible in practice — if you’re asking the agent to run a multi-step job, it’ll take a few minutes anyway. Pre-warming only matters if you need the very first transcription to be fast.</p> </blockquote> <p>Cold starts happen when Lambda hasn’t been invoked recently. For predictable usage patterns (e.g. a morning standup bot), pre-warm the Lambda before you need it:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="c"># prewarm.sh — trigger Lambda init before expected usage</span>
curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="se">\</span>
  <span class="nt">-X</span> POST <span class="s2">"</span><span class="nv">$WHISPER_LAMBDA_URL</span><span class="s2">/v1/listen?language=yue"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Token </span><span class="nv">$WHISPER_API_SECRET</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: audio/ogg"</span> <span class="se">\</span>
  <span class="nt">--data-binary</span> @sample.ogg
<span class="nb">echo</span> <span class="s2">"Lambda pre-warmed"</span>
</code></pre></div></div> <p>Schedule with cron: <code class="language-plaintext highlighter-rouge">0 8 * * * /path/to/prewarm.sh</code> (runs at 8am daily).</p> <p>Alternatively, use an EventBridge rule to ping the Lambda every few minutes — though at that frequency, Provisioned Concurrency starts making more sense cost-wise.</p> <hr/> <h2 id="conclusion">Conclusion</h2> <p>The Lambda + EFS + S3 architecture achieves:</p> <ul> <li><strong>~20-30s cold start</strong> (INT8 model on EFS); first-ever bootstrap from S3 takes ~55s (one-time only)</li> <li><strong>~10s warm invocations</strong> with <code class="language-plaintext highlighter-rouge">language=yue</code></li> <li><strong>~$0.21/month</strong> storage cost</li> <li><strong>Zero idle cost</strong> (scales to zero)</li> <li><strong>Deepgram and OpenAI compatible</strong> APIs</li> </ul> <p>The key insight: <strong>EFS is the missing piece</strong>. It provides persistent, fast storage that Lambda can access without a NAT Gateway (using the free S3 VPC Gateway Endpoint for bootstrapping).</p> <p>I couldn’t find any existing write-up of Whisper on Lambda using EFS for persistent model caching — most approaches either bundle the model in Docker (3-minute cold starts) or re-download from S3 on every cold start (no caching between instances). If you’ve seen this done before, I’d love to know.</p> <p>Two things worth knowing before you deploy:</p> <ol> <li>Use <code class="language-plaintext highlighter-rouge">Zoont/faster-whisper-large-v3-turbo-int8-ct2</code> with <code class="language-plaintext highlighter-rouge">quantization=none</code> in the sync-model workflow — it’s pre-converted to CTranslate2 INT8 and works out of the box (the <code class="language-plaintext highlighter-rouge">openai/whisper-large-v3-turbo</code> model requires conversion and can hit <code class="language-plaintext highlighter-rouge">num_mel_bins</code> config issues)</li> <li>Always pass a <code class="language-plaintext highlighter-rouge">language</code> parameter if you know it — cuts response time roughly in half</li> </ol> <p>If you’re building voice transcription on AWS and want Whisper-quality accuracy without the SageMaker complexity, give it a try.</p> <hr/> <p><em>Using EFS as a persistent model cache follows the same pattern I used earlier for <a href="https://dev.to/aws-builders/scale-a-stateful-streamlit-chatbot-with-aws-ecs-and-efs-48gm">scaling a stateful Streamlit chatbot with ECS + EFS</a> — if you’re building other stateful workloads on AWS, that one’s worth a look too.</em></p>]]></content><author><name>Gabriel Koo (AWS Community Builder)</name><email>hi@gabrielkoo.com</email></author><summary type="html"><![CDATA[Part 3 of my series on building a low-cost personal AI stack on AWS. Part 1 — Squeezing my $1k/month...]]></summary></entry><entry><title type="html">I Squeezed My $1k Monthly OpenClaw API Bill with ~$20/Month in AWS Credits — Here’s the Exact Setup</title><link href="https://gabrielkoo.com/blog/i-squeezed-my-1k-monthly-openclaw-api-bill-with-20month-in-aws-credits-heres-the-exact-setup-3gj4/" rel="alternate" type="text/html" title="I Squeezed My $1k Monthly OpenClaw API Bill with ~$20/Month in AWS Credits — Here’s the Exact Setup"/><published>2026-02-21T00:00:00+00:00</published><updated>2026-02-21T00:00:00+00:00</updated><id>https://gabrielkoo.com/blog/i-squeezed-my-1k-monthly-openclaw-api-bill-with-20month-in-aws-credits-heres-the-exact-setup-3gj4</id><content type="html" xml:base="https://gabrielkoo.com/blog/i-squeezed-my-1k-monthly-openclaw-api-bill-with-20month-in-aws-credits-heres-the-exact-setup-3gj4/"><![CDATA[<p>I’ve got OpenClaw(MoltBot(ClawdBot)) running locally on a Raspberry Pi — where computation power is scarce, and it’s gone unresponsive on me more than a few times. But even on constrained hardware, every chat turn, every memory search, every web lookup is hitting paid APIs. The bill is small at first, then it isn’t. I had been using <code class="language-plaintext highlighter-rouge">qwen3-coder-480b</code> for a week or two, and the daily cost skyrocketed to as much as $50.</p> <blockquote> <p><strong>Assumption:</strong> OpenClaw is running on hardware you already own or pay for separately — a Raspberry Pi, home server, or existing cloud instance. The compute cost of the host itself isn’t counted in the ~$20/month figure here.</p> </blockquote> <p>If you’ve picked up AWS Credits from events, the <a href="https://builder.aws.com/content/32g2lQ7kc3Py8kKIYGS15Pe8VSS/aws-community-builders-program">AWS Community Builder program</a> ($500/year), or AWS Activate — or if your company prefers to keep spend within AWS rather than onboarding yet another SaaS API provider — there’s a way to run the whole OpenClaw stack on credits.</p> <p>This is how I did it.</p> <blockquote> <p><strong>Disclaimer</strong>: The crux of this hack relies heavily on Amazon Q Developer Pro’s undocumented while generously high usage ceiling while it lasts. If it’s eventually deprecated, we will still need to switch to Kiro plans with overage pricings - still covered by AWS Credits with lower cost/token ratio.</p> </blockquote> <hr/> <h2 id="who-this-is-for">Who This Is For</h2> <p>Two very different reasons to care about this setup.</p> <p><strong>If you have AWS Credits to burn:</strong> Credits from re:Invent, AWS Community Builder, AWS Activate, or customer programs come with expiry dates. Running your AI assistant stack on them is one of the most practical ways to put idle credits to work — at ~$20/month, $100 in credits covers 5 months of the full stack. If you’re sitting on a few hundred dollars with an end-of-year deadline, this is a productive use before they lapse.</p> <p><strong>If you’re in a company with procurement or compliance requirements:</strong> Every new SaaS vendor is a TPRM exercise. OpenAI for embeddings, Perplexity for web search, Anthropic for Claude — each one is a separate vendor assessment, a separate DPA, and a separate conversation with your security team. For FSI and regulated industries, that’s not just overhead — it can be a blocker.</p> <p>AWS is likely already in your vendor register. Consolidating on Bedrock means single billing, fewer third-party relationships to manage, and data residency you control. For anything touching customer data in banking, insurance, or healthcare, that’s the difference between a quick internal approval and a 3-month procurement cycle.</p> <hr/> <h2 id="prerequisites">Prerequisites</h2> <ul> <li><strong>AWS account</strong> with Bedrock access enabled in <code class="language-plaintext highlighter-rouge">us-east-1</code> (or another US region)</li> <li><strong>AWS credentials</strong> — a <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html">Bedrock API key</a> is the simplest option if your account supports it. Otherwise, a long-term IAM access key/secret key pair works fine and is easier to manage than SSO. IAM Identity Center is only required for the Q Developer Pro layer.</li> <li><strong>Python 3.10+</strong> — used by kiro-gateway, LiteLLM, and the Nova grounding proxy</li> <li><strong>Amazon Q Developer Pro subscription</strong> ($19/user/month, credit-eligible) — required for Layer 1 (kiro-gateway). Kiro Pro, Pro+, or Power plans also work but are credit-based with overage charges — Q Developer Pro is the better deal.</li> </ul> <hr/> <h2 id="what-actually-costs-money-in-openclaw">What Actually Costs Money in OpenClaw?</h2> <p>Before reaching for solutions, it helps to know exactly where the spend goes. OpenClaw has four distinct cost centers:</p> <p><strong>1. Main model (LLM)</strong> Every chat turn, every agent action, every tool call — all routed through your primary LLM. This is the biggest variable cost. On a busy day it adds up fast.</p> <p><strong>2. Memory search (embeddings)</strong> OpenClaw’s <code class="language-plaintext highlighter-rouge">memory_search</code> tool converts your memory files into vector embeddings and queries them semantically. Every search = an embedding API call. Low cost per call, but it runs constantly in the background.</p> <p><strong>3. Web search</strong> The <code class="language-plaintext highlighter-rouge">web_search</code> tool hits Perplexity or Brave APIs. Perplexity charges per query on paid plans; Brave gives you $5/month free then charges beyond that.</p> <p><strong>4. Browser automation</strong> The <code class="language-plaintext highlighter-rouge">browser</code> tool spins up a Chromium instance for web scraping, form filling, and screenshots. Running a full browser on a low-compute machine (Raspberry Pi, t4g.small) is heavy — and cloud browser options cost per session.</p> <p>That’s it. Four layers. The goal: drive variable cost to zero.</p> <hr/> <h2 id="my-config-all-4-layers-on-aws-credits">My Config: All 4 Layers on AWS Credits</h2> <p>Here’s the full picture before we go deep:</p> <table> <thead> <tr> <th>Layer</th> <th>Solution</th> <th>Credit</th> </tr> </thead> <tbody> <tr> <td>Main model</td> <td><a href="https://github.com/jwadow/kiro-gateway">kiro-gateway</a> → Amazon Q Developer Pro</td> <td><a href="https://github.com/Jwadow">@Jwadow</a></td> </tr> <tr> <td>Memory search</td> <td>Native Bedrock embeddings via <a href="https://github.com/openclaw/openclaw/pull/20191">PR #20191</a></td> <td><a href="https://github.com/gabrielkoo">@gabrielkoo</a></td> </tr> <tr> <td>Web search</td> <td><a href="https://github.com/gabrielkoo/bedrock-web-search-proxy">bedrock-web-search-proxy</a> — Nova Grounding as Perplexity drop-in</td> <td><a href="https://github.com/gabrielkoo">@gabrielkoo</a></td> </tr> <tr> <td>Browser</td> <td><a href="https://github.com/vercel-labs/agent-browser/pull/397">agent-browser + AgentCore provider</a></td> <td><a href="https://x.com/pahudnet">@pahudnet</a></td> </tr> </tbody> </table> <p>Two of these I built myself. Two were built by other community members. All four are open source.</p> <hr/> <h2 id="layer-1-main-model--image-analysis--kiro-cli--covered-by-aws-credits">Layer 1: Main Model + Image Analysis — Kiro CLI — Covered by AWS Credits</h2> <h3 id="amazon-q-developer-pro-flat-rate-access-to-claude">Amazon Q Developer Pro: flat-rate access to Claude</h3> <p>The key difference between Amazon Q Developer Pro and Kiro Pro is the billing model. Kiro Pro is credit-based — 1,000 credits/month, pay more if you exceed them. Amazon Q Developer Pro is a flat monthly subscription: <strong>$19/user/month, no per-token billing, no surprise overages.</strong></p> <table> <thead> <tr> <th>Plan</th> <th>Cost</th> <th>Usage</th> </tr> </thead> <tbody> <tr> <td>Kiro Free</td> <td>$0/mo</td> <td>50 credits/month</td> </tr> <tr> <td>Kiro Pro</td> <td>$20/mo</td> <td>1,000 credits + $0.04/credit overage</td> </tr> <tr> <td>Kiro Pro+</td> <td>$40/mo</td> <td>2,000 credits + $0.04/credit overage</td> </tr> <tr> <td>Kiro Power</td> <td>$200/mo</td> <td>10,000 credits + $0.04/credit overage</td> </tr> <tr> <td>Amazon Q Developer Pro (legacy)</td> <td>$19/user/mo</td> <td>Flat-rate, not credit-capped</td> </tr> </tbody> </table> <blockquote> <p><strong>Note:</strong> Amazon Q Developer Pro is now a legacy plan in the Kiro ecosystem. AWS has stopped allowing new Builder ID subscriptions to Q Developer Pro — new users can only subscribe through Kiro plans. The undocumented usage limits on Q Pro are likely part of why AWS made this transition. If you’re already on Q Developer Pro, you retain access and it remains the better deal for OpenClaw.</p> </blockquote> <p>Your Q Developer Pro subscription grants access to <code class="language-plaintext highlighter-rouge">kiro-cli</code>. The documented quota is <a href="https://docs.aws.amazon.com/general/latest/gr/amazonqdev.html">10,000 inference calls/month</a> — for a personal AI assistant, that’s more than enough.</p> <blockquote> <p><strong>Real-world cost check:</strong> In 4 days of active OpenClaw usage after switching to kiro-gateway, I consumed ~40M input tokens and ~865K output tokens with Claude Sonnet. OpenClaw loads memory files, system prompts, and tool results into every turn — the context window fills up fast. At standard Bedrock pricing ($3/1M input, $15/1M output), that’s ~$135 for 4 days, or roughly <strong>$1,000/month</strong>. Q Developer Pro covers all of it for $19/month flat.</p> </blockquote> <p>In practice, I’ve been running Kiro CLI with OpenClaw daily and haven’t hit any rate limits in active use. Note: the <code class="language-plaintext highlighter-rouge">/usage</code> command isn’t available under the Q Developer Pro plan — monitor your usage via the AWS console instead. That said, after running OpenClaw with kiro-gateway for several days, I checked the Q Developer usage metrics in the AWS console and the figures hadn’t moved at all. It’s unclear whether Kiro CLI usage is counted against the same quota as Q Developer’s agentic requests, or tracked separately. The <a href="https://aws.amazon.com/q/developer/pricing/">Amazon Q Developer pricing page</a> only states “Included (with limits)” for the Pro tier — no specifics on what those limits are or how Kiro CLI calls are metered.</p> <blockquote> <p><strong>Note:</strong> Q Developer Pro requires <a href="https://docs.aws.amazon.com/singlesignon/latest/userguide/what-is.html">AWS IAM Identity Center</a> (SSO) — you can’t use it with a free Builder ID. If you’re already set up with Identity Center (common in enterprise teams and AWS Community Builders with corporate accounts), you’re good to go.</p> </blockquote> <blockquote> <p><strong>Important:</strong> Standard AWS Credits don’t cover per-token Claude usage via Anthropic’s marketplace agreement. But the Q Developer Pro subscription fee itself <strong>is</strong> credit-eligible — making the whole stack fundable with AWS credits. Kiro’s flat-rate subscription is currently the only practical way to run Claude in OpenClaw without per-token billing.</p> </blockquote> <blockquote> <p><strong>New AWS accounts:</strong> Even if you’d prefer to pay per-token via direct Bedrock API, new accounts often come with <a href="https://dev.to/aws-builders/ultra-low-bedrock-llm-rate-limits-for-new-aws-accounts-time-to-wake-up-your-inactive-aws-accounts-3no0">ultra-low default rate limits</a> that can’t reliably serve OpenClaw — even when you’re willing to pay. The flat-rate Q Developer Pro route sidesteps this entirely.</p> </blockquote> <h3 id="kiro-gateway-the-bridge">kiro-gateway: the bridge</h3> <p><a href="https://github.com/jwadow/kiro-gateway">kiro-gateway</a> — built by <a href="https://github.com/Jwadow">@Jwadow</a> — wraps Kiro CLI and exposes OpenAI-compatible and Anthropic-compatible API endpoints. OpenClaw talks to it like any other provider.</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/jwadow/kiro-gateway
<span class="nb">cd </span>kiro-gateway
pip <span class="nb">install</span> <span class="nt">-r</span> requirements.txt
<span class="nb">cp</span> .env.example .env
</code></pre></div></div> <p>Edit <code class="language-plaintext highlighter-rouge">.env</code>:</p> <pre><code class="language-env">PROXY_API_KEY="your-secret-key"
KIRO_CREDS_FILE="~/.aws/sso/cache/kiro-auth-token.json"
</code></pre> <p>Run <code class="language-plaintext highlighter-rouge">kiro-cli login</code> once to authenticate — this populates <code class="language-plaintext highlighter-rouge">KIRO_CREDS_FILE</code> automatically. (<code class="language-plaintext highlighter-rouge">kiro-cli</code> is only needed for this initial login; <code class="language-plaintext highlighter-rouge">kiro-gateway</code> reads the token it generates. Re-run if your token expires.) Then:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python main.py <span class="nt">--port</span> 9000
</code></pre></div></div> <blockquote> <p><strong>Heads up:</strong> kiro-gateway’s hardcoded fallback model list may lag behind new Claude releases. If a model isn’t showing up at <code class="language-plaintext highlighter-rouge">/v1/models</code>, add it manually to <code class="language-plaintext highlighter-rouge">FALLBACK_MODELS</code> in <code class="language-plaintext highlighter-rouge">kiro/config.py</code>.</p> </blockquote> <p>Available models via Q Developer Pro:</p> <table> <thead> <tr> <th>Model</th> <th>Best for</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">claude-sonnet-4.6</code></td> <td>General tasks, coding, writing</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">claude-haiku-4.5</code></td> <td>Fast, lightweight responses</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">claude-opus-4.6</code></td> <td>Complex reasoning, long context</td> </tr> </tbody> </table> <p>OpenClaw config:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"models"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"providers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"kiro"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"baseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://localhost:9000"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"apiKey"</span><span class="p">:</span><span class="w"> </span><span class="s2">"your-secret-key"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"api"</span><span class="p">:</span><span class="w"> </span><span class="s2">"anthropic-messages"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"agents"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"defaults"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"primary"</span><span class="p">:</span><span class="w"> </span><span class="s2">"kiro/claude-sonnet-4.6"</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"imageModel"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"primary"</span><span class="p">:</span><span class="w"> </span><span class="s2">"kiro/claude-sonnet-4.6"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <blockquote> <p><strong>Bonus:</strong> kiro-gateway works with any tool that supports OpenAI or Anthropic APIs — not just OpenClaw. To use it with Claude Code: <code class="language-plaintext highlighter-rouge">ANTHROPIC_BASE_URL=http://localhost:9000</code> and <code class="language-plaintext highlighter-rouge">ANTHROPIC_API_KEY=your-secret-key</code>.</p> </blockquote> <hr/> <h2 id="layer-2-memory-search--bedrock-embeddings--covered-by-aws-credits">Layer 2: Memory Search — Bedrock Embeddings — Covered by AWS Credits</h2> <p>OpenClaw’s <code class="language-plaintext highlighter-rouge">memory_search</code> needs an embedding model. <a href="https://docs.aws.amazon.com/nova/latest/userguide/nova-embeddings.html">Amazon Nova Multimodal Embeddings</a> costs ~$0.00014 per 1K tokens — fractions of a cent per query, and covered by AWS Credits.</p> <p>OpenClaw’s native Bedrock provider doesn’t wire up embeddings cleanly yet — <a href="https://github.com/openclaw/openclaw/pull/24892">PR #24892</a> - (I made a novice mistake with <a href="https://github.com/openclaw/openclaw/pull/20191">PR #20191</a>) is pending merge. Until then, you’ll need a local OpenAI-compatible proxy in front of Bedrock. Two options:</p> <h3 id="option-a-litellm">Option A: LiteLLM</h3> <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># litellm_config.yaml</span>
<span class="na">model_list</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">model_name</span><span class="pi">:</span> <span class="s">nova-2-multimodal-embeddings-v1.0</span>
    <span class="na">litellm_params</span><span class="pi">:</span>
      <span class="na">model</span><span class="pi">:</span> <span class="s">bedrock/amazon.nova-2-multimodal-embeddings-v1:0</span>
      <span class="na">aws_region_name</span><span class="pi">:</span> <span class="s">us-east-1</span>

<span class="na">litellm_settings</span><span class="pi">:</span>
  <span class="na">drop_params</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">master_key</span><span class="pi">:</span> <span class="s2">"</span><span class="s">local-only"</span>
</code></pre></div></div> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install</span> <span class="s1">'litellm[proxy]'</span>
litellm <span class="nt">--config</span> litellm_config.yaml <span class="nt">--port</span> 4000
</code></pre></div></div> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"memorySearch"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nl">"enabled"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"provider"</span><span class="p">:</span><span class="w"> </span><span class="s2">"openai"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"remote"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"baseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://localhost:4000"</span><span class="p">,</span><span class="w"> </span><span class="nl">"apiKey"</span><span class="p">:</span><span class="w"> </span><span class="s2">"local-only"</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"nova-2-multimodal-embeddings-v1.0"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <h3 id="option-b-bedrock-access-gateway-function-url-serverless-no-fixed-cost">Option B: bedrock-access-gateway-function-url (serverless, no fixed cost)</h3> <p>My own fork of the original <code class="language-plaintext highlighter-rouge">bedrock-access-gateway</code> — deployed as a Lambda Function URL instead of ALB+Fargate, so there’s no $16+/month fixed cost. Full writeup: <a href="https://dev.to/aws-builders/use-amazon-bedrock-models-via-an-openai-api-compatible-serverless-endpoint-now-without-fixed-cost-5hf5">Use Amazon Bedrock Models with OpenAI SDKs with a Serverless Proxy Endpoint</a>.</p> <blockquote> <p><strong>Note:</strong> My <a href="https://github.com/aws-samples/bedrock-access-gateway/pull/222">PR #222</a> for Nova 2 embedding support against the original <code class="language-plaintext highlighter-rouge">bedrock-access-gateway</code> project has been merged — so my fork pulls from this upstream automatically via <code class="language-plaintext highlighter-rouge">prepare_source.sh</code>.</p> </blockquote> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone <span class="nt">--depth</span><span class="o">=</span>1 https://github.com/gabrielkoo/bedrock-access-gateway-function-url
<span class="nb">cd </span>bedrock-access-gateway-function-url
./prepare_source.sh
sam build
sam deploy <span class="nt">--guided</span>
</code></pre></div></div> <p>Grab the <code class="language-plaintext highlighter-rouge">FunctionUrl</code> output after deploy, then:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"memorySearch"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nl">"enabled"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"provider"</span><span class="p">:</span><span class="w"> </span><span class="s2">"openai"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"remote"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"baseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://&lt;your-function-url&gt;.lambda-url.us-east-1.on.aws"</span><span class="p">,</span><span class="w"> </span><span class="nl">"apiKey"</span><span class="p">:</span><span class="w"> </span><span class="s2">"your-api-key"</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"amazon.nova-2-multimodal-embeddings-v1:0"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <blockquote> <p><strong>Region note:</strong> <code class="language-plaintext highlighter-rouge">amazon.nova-2-multimodal-embeddings-v1:0</code> availability varies — check the <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/models-regions.html">Bedrock model availability page</a>. Make sure your IAM credentials have <code class="language-plaintext highlighter-rouge">bedrock:InvokeModel</code> in your target region.</p> </blockquote> <p>Once <a href="https://github.com/openclaw/openclaw/pull/24892">PR #24892</a> merges, no proxy needed — the config simplifies to:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"memorySearch"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nl">"enabled"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"provider"</span><span class="p">:</span><span class="w"> </span><span class="s2">"bedrock"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"amazon.nova-2-multimodal-embeddings-v1:0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"region"</span><span class="p">:</span><span class="w"> </span><span class="s2">"us-east-1"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <hr/> <h2 id="layer-3-web-search--nova-grounding-proxy--covered-by-aws-credits">Layer 3: Web Search — Nova Grounding Proxy — Covered by AWS Credits</h2> <p>I built <a href="https://github.com/gabrielkoo/bedrock-web-search-proxy"><code class="language-plaintext highlighter-rouge">bedrock-web-search-proxy</code></a> — a FastAPI wrapper that makes Bedrock Nova Grounding look like the Perplexity Sonar API. No Perplexity or Brave API key needed. Runs entirely on AWS Credits.</p> <p>Full writeup: <a href="https://dev.to/aws-builders/drop-in-perplexity-sonar-replacement-with-aws-bedrock-nova-grounding-35o9">Drop-in Perplexity Sonar Replacement with AWS Bedrock Nova Grounding</a>.</p> <h3 id="option-a-run-locally">Option A: Run locally</h3> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/gabrielkoo/bedrock-web-search-proxy
<span class="nb">cd </span>bedrock-web-search-proxy
pip <span class="nb">install </span>fastapi uvicorn boto3
uvicorn main:app <span class="nt">--port</span> 7000
</code></pre></div></div> <h3 id="option-b-lambda-function-url-zero-idle-cost">Option B: Lambda Function URL (zero idle cost)</h3> <p>See the <a href="https://github.com/gabrielkoo/bedrock-web-search-proxy">deployment guide in the repo</a> — SAM-based, arm64, python3.13. Once deployed, you get a persistent HTTPS endpoint with no local process to manage.</p> <p>OpenClaw config:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"tools"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"web"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"search"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"provider"</span><span class="p">:</span><span class="w"> </span><span class="s2">"perplexity"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"perplexity"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"apiKey"</span><span class="p">:</span><span class="w"> </span><span class="s2">"your-proxy-key"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"baseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://localhost:7000/v1"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sonar-pro"</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <blockquote> <p>All US Nova CRIS (Cross-Region Inference Services) profiles support web grounding (<code class="language-plaintext highlighter-rouge">us.amazon.nova-premier-v1:0</code>, <code class="language-plaintext highlighter-rouge">us.amazon.nova-pro-v1:0</code>, etc.). Native model IDs without the <code class="language-plaintext highlighter-rouge">us.</code> prefix do NOT work — must use CRIS profiles. Web grounding is US regions only (us-east-1, us-east-2, us-west-2).</p> </blockquote> <hr/> <h2 id="layer-4-cloud-browser--bedrock-agentcore--covered-by-aws-credits">Layer 4: Cloud Browser — Bedrock AgentCore — Covered by AWS Credits</h2> <p><a href="https://github.com/vercel-labs/agent-browser"><code class="language-plaintext highlighter-rouge">agent-browser</code></a> by Vercel Labs, with the AgentCore provider contributed by <a href="https://github.com/pahud">Pahud Hsieh</a> (<a href="https://x.com/pahudnet">@pahudnet</a>) — <a href="https://github.com/vercel-labs/agent-browser/pull/397">PR #397</a>.</p> <p>The browser runs in AWS — no local Chromium needed. Particularly useful on low-compute instances (Pi, t4g.small) where running a local browser would be too heavy. Covered by AWS Credits.</p> <p>Node.js and pnpm required. Since <a href="https://github.com/vercel-labs/agent-browser/pull/397">PR #397</a> isn’t merged yet, check out the branch directly:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/vercel-labs/agent-browser
<span class="nb">cd </span>agent-browser
git fetch origin pull/397/head:agentcore
git checkout agentcore
pnpm <span class="nb">install</span> <span class="o">&amp;&amp;</span> pnpm build
</code></pre></div></div> <p>Then use it:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>agent-browser <span class="nt">-p</span> agentcore open https://example.com
agent-browser close
</code></pre></div></div> <p>Your AWS identity needs these IAM permissions:</p> <ul> <li><code class="language-plaintext highlighter-rouge">bedrock-agentcore:StartBrowserSession</code></li> <li><code class="language-plaintext highlighter-rouge">bedrock-agentcore:ConnectBrowserAutomationStream</code></li> <li><code class="language-plaintext highlighter-rouge">bedrock-agentcore:StopBrowserSession</code></li> </ul> <blockquote> <p>On a desktop machine with enough RAM, local CDP (OpenClaw’s built-in browser) is free and works fine. AgentCore is the play for headless/low-compute setups.</p> </blockquote> <hr/> <h2 id="the-cost-math">The Cost Math</h2> <p>Without this setup, Claude Sonnet alone runs ~<strong>$1,000/month</strong> at standard Bedrock pricing — based on real token usage from my own sessions. OpenClaw’s large context window (memory files, system prompts, tool results loaded every turn) means the token bill compounds fast.</p> <p>The full stack with this setup runs at <strong>~$20/month</strong>:</p> <ul> <li><strong>$19/mo</strong> — Amazon Q Developer Pro (flat-rate, covers all LLM calls)</li> <li><strong>≤$1/mo</strong> — Bedrock embeddings for memory search (Nova 2 at $0.00014/1K tokens)</li> </ul> <p>Web search and browser automation are covered by AWS Credits — no separate line item.</p> <p>With <strong>$100 in AWS Credits</strong>, you cover roughly <strong>5 months</strong> of the full stack. Both the Q Developer Pro subscription and Bedrock embeddings are credit-eligible — if you’re an AWS Community Builder, that $500/year allocation more than covers it.</p> <h3 id="where-aws-credits-come-from">Where AWS Credits Come From</h3> <ul> <li><strong>AWS event participant/speaker</strong> — re:Invent, Summit, local user groups</li> <li><strong>AWS Community Builder</strong> — $500/year for active builders (<a href="https://builder.aws.com/content/32g2lQ7kc3Py8kKIYGS15Pe8VSS/aws-community-builders-program">builder.aws.com</a>). The application opens a few rounds per year — I’m one of the builders in the program.</li> <li><strong>AWS Customer Council</strong> — participation typically includes credits</li> <li><strong>AWS Activate</strong> (startups) — up to $100K</li> <li><strong>AWS Educate / Academy</strong> — educators and students</li> </ul> <p>Check your balance: <a href="https://console.aws.amazon.com/billing/home#/credits">console.aws.amazon.com/billing/home#/credits</a></p> <hr/> <h2 id="closing">Closing</h2> <p>Four layers. Two built by community members, two I built myself. All open source, all running on AWS Credits.</p> <p>To be clear: <strong>kiro-gateway is the most crucial piece here.</strong> <a href="https://github.com/Jwadow">@Jwadow</a> built the bridge that makes Claude accessible without per-token billing — I built the embedding proxy and web search proxy to fill the remaining gaps. <a href="https://github.com/gabrielkoo/bedrock-web-search-proxy">Web search</a> and <a href="https://github.com/vercel-labs/agent-browser">cloud browser</a> (Layers 3 and 4) are purely AWS Credits — no subscription, per-token billing well covered by AWS Credits.</p> <p>If you’re already an AWS Community Builder or have credits sitting in your account, there’s no reason to be paying per-token for a personal AI assistant. Wire it up once, and the stack runs itself.</p> <p>Put those credits to work.</p>]]></content><author><name>Gabriel Koo (AWS Community Builder)</name><email>hi@gabrielkoo.com</email></author><summary type="html"><![CDATA[I've got OpenClaw running locally on a Raspberry Pi — where computation power is scarce, and it's...]]></summary></entry><entry><title type="html">Drop-in Perplexity Sonar Replacement with AWS Bedrock Nova Grounding</title><link href="https://gabrielkoo.com/blog/drop-in-perplexity-sonar-replacement-with-aws-bedrock-nova-grounding-35o9/" rel="alternate" type="text/html" title="Drop-in Perplexity Sonar Replacement with AWS Bedrock Nova Grounding"/><published>2026-02-20T00:00:00+00:00</published><updated>2026-02-20T00:00:00+00:00</updated><id>https://gabrielkoo.com/blog/drop-in-perplexity-sonar-replacement-with-aws-bedrock-nova-grounding-35o9</id><content type="html" xml:base="https://gabrielkoo.com/blog/drop-in-perplexity-sonar-replacement-with-aws-bedrock-nova-grounding-35o9/"><![CDATA[<p>If you’re running an AI assistant or agent framework that uses Perplexity’s Sonar API for web search, you’re paying per query — or burning through your monthly credit allocation faster than you’d like.</p> <p>I’m on Perplexity Pro, which comes with $5/month in API credits. Sounds fine until you hit mid-month and realize OpenClaw has quietly burned through all of it. I wanted something uncapped that didn’t add another bill. If you’re an AWS user with any credits sitting around — that $25 from a workshop, an event promo, or re:Invent swag — there’s a better option: route those queries through Amazon Bedrock’s Nova Premier grounding instead.</p> <p>I built <a href="https://github.com/gabrielkoo/bedrock-web-search-proxy"><code class="language-plaintext highlighter-rouge">bedrock-web-search-proxy</code></a>, a FastAPI proxy that makes Bedrock Nova Premier look exactly like the Perplexity Sonar API. Change one URL, keep everything else the same.</p> <h2 id="what-is-nova-grounding">What is Nova Grounding?</h2> <p>Amazon Nova Premier supports a <code class="language-plaintext highlighter-rouge">nova_grounding</code> system tool that lets the model search the web in real-time and return answers with citations — similar to Perplexity Sonar. The difference: it runs on Bedrock, so it counts against your AWS credits rather than a separate Perplexity subscription.</p> <h2 id="why-not-just-use-brave-searchs-free-tier">Why Not Just Use Brave Search’s Free Tier?</h2> <p>Brave does have an AI Answers API that returns synthesized answers with citations — similar to Perplexity. Two catches though:</p> <ol> <li><strong>Credit card required</strong> — even the $5/month free tier needs a card on file as an anti-fraud measure</li> <li><strong>Undocumented model</strong> — Brave doesn’t clearly disclose which LLM powers the answers, so you’re trusting a black box</li> </ol> <p>With Nova grounding, you know exactly what’s running (Nova Premier on Bedrock), and it counts against AWS credits you likely already have. No new billing relationship, no mystery model.</p> <h2 id="apps-that-use-perplexity-api">Apps That Use Perplexity API</h2> <p>The wrapper is a drop-in for any app that supports Perplexity as a provider:</p> <ul> <li><strong>OpenClaw</strong> — <code class="language-plaintext highlighter-rouge">tools.web.search.perplexity.baseUrl</code> config</li> <li><strong>Open WebUI</strong> — web search integration</li> <li><strong>LibreChat</strong> — via Perplexity MCP server</li> <li><strong>Cursor</strong> — Perplexity MCP for web research</li> <li><strong>Continue.dev</strong> — Sonar models for codebase context</li> <li><strong>AnythingLLM</strong> — Perplexity as cloud LLM provider</li> <li><strong>LiteLLM</strong> — web search interception</li> </ul> <h2 id="proof-its-actually-grounded-not-hallucinated">Proof It’s Actually Grounded (Not Hallucinated)</h2> <p>Here’s a direct API call asking for the current Bitcoin price:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> http://localhost:7000/v1/chat/completions <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{
    "model": "nova-premier-web-grounding",
    "messages": [{"role": "user", "content": "What is the Bitcoin price right now?"}],
    "max_tokens": 200
  }'</span>
</code></pre></div></div> <p>Response:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"choices"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="w">
    </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"content"</span><span class="p">:</span><span class="w"> </span><span class="s2">"The current price of Bitcoin (BTC) is $67,254.57 USD, reflecting a 0.54% increase in the last 24 hours. Last updated February 20, 2026 at 04:34 UTC."</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}],</span><span class="w">
  </span><span class="nl">"citations"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"https://www.latestly.com/technology/bitcoin-price-today-february-20-2026-btc-price-at-usd-67243-up-compared-to-yesterdays-usd-66941-mark-7321498.html"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"https://www.binance.com/en/price/bitcoin"</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p>The citation URL contains today’s date in the slug. Not hallucinated — Nova Premier actually fetched and synthesized live web content.</p> <h2 id="setup">Setup</h2> <h3 id="1-install-and-run-one-line-no-cloning-needed">1. Install and run (one line, no cloning needed)</h3> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uvx <span class="nt">--from</span> git+https://github.com/gabrielkoo/bedrock-web-search-proxy bedrock-web-search-proxy
</code></pre></div></div> <p>Or run directly from the raw script:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv run https://raw.githubusercontent.com/gabrielkoo/bedrock-web-search-proxy/main/main.py
</code></pre></div></div> <p>Both require <a href="https://docs.astral.sh/uv/">uv</a> and AWS credentials with <code class="language-plaintext highlighter-rouge">bedrock:InvokeModel</code> on <code class="language-plaintext highlighter-rouge">us.amazon.nova-premier-v1:0</code>. Region defaults to <code class="language-plaintext highlighter-rouge">us-east-1</code> — override with <code class="language-plaintext highlighter-rouge">AWS_DEFAULT_REGION</code> if needed.</p> <h3 id="2-configure-your-app">2. Configure your app</h3> <p>For <strong>OpenClaw</strong>, update <code class="language-plaintext highlighter-rouge">~/.openclaw/openclaw.json</code>:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"tools"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"web"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"search"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"provider"</span><span class="p">:</span><span class="w"> </span><span class="s2">"perplexity"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"perplexity"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"baseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://localhost:7000/v1"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"apiKey"</span><span class="p">:</span><span class="w"> </span><span class="s2">"nova-grounding"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"nova-premier-web-grounding"</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <blockquote> <p>⚠️ The <code class="language-plaintext highlighter-rouge">apiKey</code> must <strong>not</strong> be a real <code class="language-plaintext highlighter-rouge">pplx-</code> key — OpenClaw detects that prefix and overrides <code class="language-plaintext highlighter-rouge">baseUrl</code> back to Perplexity’s servers.</p> </blockquote> <p>For other apps, just point the Perplexity base URL to <code class="language-plaintext highlighter-rouge">http://your-host:7000/v1</code> and use any model name — the wrapper routes everything to Nova Premier.</p> <h2 id="model-aliases">Model Aliases</h2> <p>All standard Perplexity model names are accepted and routed to Nova Premier (the only Nova model that currently supports the grounding tool):</p> <table> <thead> <tr> <th>Request model</th> <th>Bedrock model</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">nova-premier-web-grounding</code></td> <td><code class="language-plaintext highlighter-rouge">us.amazon.nova-premier-v1:0</code></td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">sonar-pro</code>, <code class="language-plaintext highlighter-rouge">sonar-pro-online</code></td> <td><code class="language-plaintext highlighter-rouge">us.amazon.nova-premier-v1:0</code></td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">sonar</code>, <code class="language-plaintext highlighter-rouge">sonar-mini</code>, <code class="language-plaintext highlighter-rouge">sonar-turbo</code></td> <td><code class="language-plaintext highlighter-rouge">us.amazon.nova-premier-v1:0</code></td> </tr> </tbody> </table> <h2 id="cost">Cost</h2> <p>Nova Premier usage counts against your AWS credits — so if you have any sitting around (a $25 promo from an AWS event, workshop, or re:Invent swag bag), this is effectively free. Check your <a href="https://console.aws.amazon.com/billing/home#/credits">Billing console</a> — you might have more than you think.</p> <ul> <li><strong>AWS Community Builders</strong>: covered by $500/year credits</li> <li><strong>Others with AWS credits</strong>: same deal — credits apply</li> <li><strong>No credits</strong>: check the <a href="https://aws.amazon.com/bedrock/pricing/">Bedrock pricing page</a> for current Nova Premier rates</li> </ul> <h2 id="caveats">Caveats</h2> <ul> <li><strong>Streaming doesn’t return <code class="language-plaintext highlighter-rouge">citations[]</code></strong> — Nova limitation. Non-streaming works fine, and OpenClaw’s <code class="language-plaintext highlighter-rouge">web_search</code> tool uses non-streaming.</li> <li><strong><code class="language-plaintext highlighter-rouge">MAX_CONCURRENT</code> semaphore</strong> defaults to 5 — tune via env var if needed.</li> <li><strong>Region</strong>: Nova Premier grounding requires <code class="language-plaintext highlighter-rouge">us-east-1</code>.</li> </ul> <h2 id="wrapping-up">Wrapping Up</h2> <p>If you’re already on AWS Bedrock for your LLM workloads, there’s no reason to pay Perplexity separately for web-grounded search. The wrapper is ~350 lines of Python, has 44 tests, and is OpenAI SDK-compatible — so it works with anything that speaks the Perplexity or OpenAI chat completions API.</p> <p>Repo: <a href="https://github.com/gabrielkoo/bedrock-web-search-proxy">github.com/gabrielkoo/bedrock-web-search-proxy</a></p>]]></content><author><name>Gabriel Koo (AWS Community Builder)</name><email>hi@gabrielkoo.com</email></author><summary type="html"><![CDATA[If you're running an AI assistant or agent framework that uses Perplexity's Sonar API for web search,...]]></summary></entry><entry><title type="html">AWS Silently Releases Kimi K2.5 and GLM 4.7 Models to Bedrock</title><link href="https://gabrielkoo.com/blog/aws-silently-releases-kimi-k25-and-glm-47-models-to-bedrock-1514/" rel="alternate" type="text/html" title="AWS Silently Releases Kimi K2.5 and GLM 4.7 Models to Bedrock"/><published>2026-02-08T00:00:00+00:00</published><updated>2026-02-08T00:00:00+00:00</updated><id>https://gabrielkoo.com/blog/aws-silently-releases-kimi-k25-and-glm-47-models-to-bedrock-1514</id><content type="html" xml:base="https://gabrielkoo.com/blog/aws-silently-releases-kimi-k25-and-glm-47-models-to-bedrock-1514/"><![CDATA[<p><strong>[UPDATE 10 Feb 2026]</strong> - It seems it was part of AWS’s rollout plan for rolling out open weight models for Kiro and Kiro CLI! <a href="https://kiro.dev/blog/open-weight-models/">Open weight models are here: more choice, more speed, less cost</a></p> <p>But interestingly among the models covered in this article, only DeepSeek v3.2, Minimax 2.1 and Qwen Coder next were covered. Moonshot K2.5 and GLM-4.7 were missing. ———— I was refreshing my <a href="https://amazonbedrockmodels.github.io">Bedrock model catalog</a> script our of random curiosity when a few unfamiliar model IDs showed up in us-east-1. No AWS blog post. No tweet thread. Just a few new entries in the API response.</p> <p><strong>If you’ve been waiting for a Claude-adjacent model you could swap in seamlessly via AWS credits — this is it.</strong></p> <p>But there’s a drawback for early adopters - Do read till the end to learn about the flaw!</p> <p><strong>Kimi K2.5</strong> (by Moonshot AI), <strong>GLM 4.7</strong> (by Zhipu AI), and several other new models like DeepSeek 3.2 and Qwen3 Coder Next are now live on Bedrock, all with full support for the Converse API, tool calling, and — in Kimi K2.5’s case — native image understanding.</p> <p><img src="/assets/img/44497e3244dd.png" alt="Image description"/></p> <p><img src="/assets/img/3b89e0bed041.png" alt="Image description"/></p> <p><strong>Quick note:</strong> These models aren’t listed in the <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html">AWS Bedrock models-supported documentation</a>, yet they’re fully functional via the Converse API. That’s the whole “silent release” thing — available in production, just not reflected in the canonical docs yet. Worth bookmarking your region’s actual model list from the Bedrock console instead of relying solely on the written guides.</p> <h2 id="the-models-what-just-landed">The models: what just landed</h2> <p><img src="/assets/img/e1fb65a68813.png" alt="Image description"/></p> <p><strong>Kimi K2.5</strong> (<a href="https://www.kimi.com/blog/kimi-k2.5.html">Moonshot AI blog</a>) is the eye-catcher here:</p> <ul> <li><strong>Tool calling (function calling):</strong> ✓ Fully supported via Bedrock Converse API</li> <li><strong>Image understanding:</strong> ✓ Native image inputs (base64 or URL)</li> <li><strong>Code generation:</strong> In my testing, it held its own against Claude 4.5 Sonnet on typical coding prompts — it handled a multi-file refactor of a FastAPI router cleanly on the first try</li> <li><strong>Bedrock Model ID:</strong> <code class="language-plaintext highlighter-rouge">moonshotai.kimi-k2.5</code></li> <li><strong>Availability:</strong> us-east-1, us-west-2 (and expanding)</li> <li><strong>Use case fit:</strong> Drop-in replacement for Claude if you’re already on AWS credits</li> </ul> <p><strong>GLM 4.7</strong> (<a href="https://z.ai/blog/glm-4.7">Zhipu AI blog</a>) fills a quieter but useful role:</p> <ul> <li><strong>Tool calling:</strong> ✓ Supported, though less aggressively tested in my flows</li> <li><strong>Code generation:</strong> Strong; competitive with Deepseek for certain workloads</li> <li><strong>Bedrock Model ID:</strong> <code class="language-plaintext highlighter-rouge">zai.glm-4.7(-flash)</code></li> <li><strong>Availability:</strong> us-east-1, us-west-2</li> <li><strong>Use case fit:</strong> Solid all-arounder; good for prompts that don’t strictly require image handling</li> </ul> <p><strong>The real unlock:</strong> Both are live on <code class="language-plaintext highlighter-rouge">converse</code> API, which means they work seamlessly with Bedrock’s function-calling infrastructure.</p> <h3 id="when-to-pick-which">When to pick which</h3> <table> <thead> <tr> <th>Need</th> <th>Pick</th> <th>Why</th> </tr> </thead> <tbody> <tr> <td>Image understanding + tool calling</td> <td><strong>Kimi K2.5</strong></td> <td>Only Bedrock open-weight flagship model with both</td> </tr> <tr> <td>Text-only tasks, cost-conscious</td> <td><strong>GLM 4.7</strong></td> <td>Solid all-arounder, no vision overhead</td> </tr> <tr> <td>Maximum reliability &amp; ecosystem</td> <td><strong>Claude</strong></td> <td>Battle-tested, widest documentation</td> </tr> </tbody> </table> <h2 id="why-this-matters-for-vibe-coding">Why this matters for vibe coding</h2> <p><em>“Vibe coding”</em> — the practice of rapidly iterating on code with LLM assistance, swapping models mid-session, and optimizing for flow over perfection — lives or dies on how frictionless your model-switching is.</p> <p>If you’re sitting on expiring AWS credits (I’ve got ~$700 by July 2026), the bottleneck isn’t usually “which model is smartest?” — it’s “how fast can I swap without rewriting everything?”</p> <p>Kimi K2.5 solves a real pain point: until now, if you wanted image understanding + tool calling + AWS-native billing, you were stuck with Claude. And the only way you could have done so is via purchasing Kiro CLI subscription ($20/$40/$200 per month) - Since Anthropic Claude models are not covered by the typical AWS Credits.</p> <p>I initially considered subscribing to the $200 Kiro Power plan, but then I am not confident that I could utilize the plan fully every month, but I’m worried that sticking to the $20/$40 plans will result to paying for Kiro credit overages (which is double of the average plan price per credit). Therefore any PAYG option would perfectly fit my usage pattern.</p> <p>So now you have an option that:</p> <ol> <li><strong>Bills directly to your AWS account</strong> — no vendor intermediary, no separate API key, just your existing credits burning down</li> <li>Runs on the same Bedrock Converse API</li> <li>Calls tools reliably</li> <li>Natively understands images</li> </ol> <p>For experimentation loops (refactors, code generation, visual analysis), that’s a genuinely useful escape hatch.</p> <h2 id="the-lightweight-setup-local-litellm-gateway">The lightweight setup: local LiteLLM gateway</h2> <p>You don’t need a complex setup. My entire gateway is:</p> <ul> <li>A Python venv with <code class="language-plaintext highlighter-rouge">litellm</code> installed</li> <li>A single YAML config file (shown in the next section)</li> <li>A systemd unit to keep it running on port 4000</li> </ul> <p>No containers, no Kubernetes. One command to install, one service file to manage. Once running, any client on that machine calls <code class="language-plaintext highlighter-rouge">http://localhost:4000/chat/completions</code> with the standard OpenAI format, and LiteLLM translates it to Bedrock Converse API automatically.</p> <p><strong>Performance note:</strong> In my testing, the LiteLLM translation layer adds negligible latency (~20–50ms overhead). Streaming responses from Kimi K2.5 feel comparable to calling Claude directly — first tokens arrive within 1–2 seconds for typical prompts.</p> <h2 id="bonus-claude-code--opencode-integration">Bonus: Claude Code &amp; OpenCode integration</h2> <p>Here’s the slightly cheeky part: you can point <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a> or <a href="https://opencode.ai/">OpenCode</a> at your local LiteLLM gateway and route requests through to Kimi K2.5 or GLM 4.7 on Bedrock — all while staying on your AWS credits.</p> <p>LiteLLM supports the Anthropic <code class="language-plaintext highlighter-rouge">/v1/messages</code> API endpoint, so it’s a two-liner to set up:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">ANTHROPIC_BASE_URL</span><span class="o">=</span>http://localhost:4000
<span class="nb">export </span><span class="nv">ANTHROPIC_AUTH_TOKEN</span><span class="o">=</span>sk-your-litellm-key
<span class="nb">export </span><span class="nv">ANTHROPIC_MODEL</span><span class="o">=</span>kimi-k2.5
<span class="nb">export </span><span class="nv">DISABLE_PROMPT_CACHING</span><span class="o">=</span><span class="nb">true</span>
</code></pre></div></div> <p>The <code class="language-plaintext highlighter-rouge">DISABLE_PROMPT_CACHING=true</code> is essential here (Special thanks my colleague <em><strong>[at]Marty</strong></em> for troubleshooting and fixing that) - since by default Claude Code tries to apply prompt caching for speed, but not every model on Amazon Bedrock supports that:</p> <p><img src="/assets/img/b70d22146480.png" alt="Image description"/></p> <p>Then launch Claude Code or OpenCode as usual. LiteLLM intercepts the Anthropic-format requests and translates them to Bedrock Converse calls. It’s not officially blessed by Anthropic, but it works cleanly for local experimentation — and your AWS credits take the hit instead of your Anthropic billing.</p> <p><img src="/assets/img/00e6c23d9623.png" alt="Image description"/></p> <h2 id="config-explicit-about-capabilities">Config: explicit about capabilities</h2> <p>Here’s how I route Kimi K2.5 and GLM 4.7:</p> <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">model_list</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">model_name</span><span class="pi">:</span> <span class="s">kimi-k2.5</span>
    <span class="na">litellm_params</span><span class="pi">:</span>
      <span class="na">model</span><span class="pi">:</span> <span class="s">bedrock/converse/moonshotai.kimi-k2.5</span>
      <span class="na">aws_region_name</span><span class="pi">:</span> <span class="s">us-east-1</span>
      <span class="na">allowed_openai_params</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">reasoning_effort'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">tools'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">tool_choice'</span><span class="pi">]</span>
    <span class="na">model_info</span><span class="pi">:</span>
      <span class="na">mode</span><span class="pi">:</span> <span class="s">completion</span>

  <span class="pi">-</span> <span class="na">model_name</span><span class="pi">:</span> <span class="s">glm-4.7</span>
    <span class="na">litellm_params</span><span class="pi">:</span>
      <span class="na">model</span><span class="pi">:</span> <span class="s">bedrock/converse/zai.glm-4.7</span>
      <span class="na">aws_region_name</span><span class="pi">:</span> <span class="s">us-east-1</span>
      <span class="na">allowed_openai_params</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">reasoning_effort'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">tools'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">tool_choice'</span><span class="pi">]</span>    
    <span class="na">model_info</span><span class="pi">:</span>
      <span class="na">mode</span><span class="pi">:</span> <span class="s">completion</span>

<span class="na">litellm_settings</span><span class="pi">:</span>
  <span class="na">modify_params</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">log_responses</span><span class="pi">:</span> <span class="kc">true</span>
</code></pre></div></div> <p>Key patterns here:</p> <ul> <li><strong>Friendly names</strong> (<code class="language-plaintext highlighter-rouge">kimi-k2.5</code>, <code class="language-plaintext highlighter-rouge">glm-4.7</code>) instead of long model IDs</li> <li><strong>Explicit capability flags</strong> (<code class="language-plaintext highlighter-rouge">supports_function_calling</code>, <code class="language-plaintext highlighter-rouge">supports_vision</code>)</li> <li><strong><code class="language-plaintext highlighter-rouge">modify_params: true</code></strong> for Bedrock edge-case smoothing</li> <li><strong>Single region</strong> (us-east-1) since both models are there</li> </ul> <p>The capability flags matter. They let your orchestration layer (or agent framework) gracefully degrade if a model can’t do tools or images. No more “half-attempt to call a function and fail mysteriously.”</p> <h2 id="testing-quick-verification">Testing: quick verification</h2> <p>I tested both models with the Bedrock Converse API in us-east-1. Here’s what actually happened:</p> <p><strong>Kimi K2.5:</strong> I threw a “get current weather in Tokyo” tool spec at it — standard JSON Schema function definition, nothing fancy. It correctly structured the function call on the first attempt, including proper argument types in the response. For code generation, I asked it to refactor a Python CLI script into async; the output was clean and ran without edits. Image support is declared in the model schema but I haven’t validated it hands-on yet — that’s next on my list.</p> <p><strong>GLM 4.7:</strong> Solid on text queries and code generation. Tool calling works, though it was slightly less eager to invoke tools unprompted compared to Kimi — it sometimes answered directly when I expected a function call. No image support, as expected; Zhipu hasn’t added vision capabilities to GLM 4.7.</p> <h2 id="why-the-quiet-release">Why the quiet release?</h2> <p>These aren’t show-stopping announcements. They’re bread-and-butter additions to Bedrock’s model portfolio. AWS likely brought them in as part of an ongoing expansion to reduce vendor lock-in on “you have to use Claude for everything.” That’s healthy — more options, better pricing pressure, cleaner credit utilization.</p> <p>This is a pattern, not an anomaly. Anthropic Claude, AI21 Jamba, and several Mistral variants all appeared on Bedrock before official blog posts or documentation updates. If you’re only checking AWS launch announcements, you’re always behind.</p> <blockquote> <p><strong>📌 That’s exactly why I built <a href="https://amazonbedrockmodels.github.io">amazonbedrockmodels.github.io</a></strong> — a living catalog of what’s <em>actually</em> available on Bedrock, in which regions, and what each model can do. Bookmark it. It updates faster than the docs.</p> </blockquote> <h2 id="the-model-swapping-checklist">The model-swapping checklist</h2> <p>If you want to swap models without rewriting your code:</p> <ol> <li><strong>Use a gateway</strong> (LiteLLM, LLMProxy, or similar) to normalize requests</li> <li><strong>Pin the Bedrock route explicitly</strong> (<code class="language-plaintext highlighter-rouge">bedrock/converse/modelid</code>) in your config</li> <li><strong>Mark capability per model</strong> (tool calling, vision, etc.) — don’t assume</li> <li><strong>Test the tool spec</strong> — even “supported” models sometimes have quirky implementations</li> <li><strong>Keep a catalog</strong> so you don’t rediscover the same model twice</li> </ol> <p>Kimi K2.5 fits this playbook cleanly. It’s a genuine Claude replacement for Bedrock users, not a “wait and see if it works” experiment.</p> <h2 id="next-steps">Next steps</h2> <ul> <li><strong>If you’re on AWS credits:</strong> Spin up a local LiteLLM instance and try both models</li> <li><strong>If you find more quietly-available models:</strong> Open a PR against the catalog or message me</li> <li><strong>If you’re in an AWS org:</strong> Check your Bedrock region — availability is still expanding</li> </ul> <p>The AWS credits will expire whether you use them or not. Might as well pick models that fit your workflow instead of forcing your workflow around Kiro CLI only.</p> <hr/> <p>P.S. During my testing, indeed I observed occasional longer LLM inference times - but I guess AWS is working on providing more compute capability on these newer beta models.</p> <hr/> <h2 id="references">References</h2> <p>[1] <a href="https://www.kimi.com/blog/kimi-k2.5.html">Moonshot AI — Kimi K2.5 Announcement</a> [2] <a href="https://z.ai/blog/glm-4.7">Zhipu AI — GLM 4.7 Announcement</a> [3] <a href="https://docs.aws.amazon.com/cli/latest/reference/bedrock-runtime/converse.html">AWS Bedrock — Converse API Reference</a> [4] <a href="https://docs.litellm.ai/docs/providers/bedrock">LiteLLM — AWS Bedrock Provider Documentation</a> [5] <a href="https://amazonbedrockmodels.github.io">Unofficial - Amazon Bedrock Model Catalog I Created</a></p>]]></content><author><name>Gabriel Koo (AWS Community Builder)</name><email>hi@gabrielkoo.com</email></author><summary type="html"><![CDATA[[UPDATE 10 Feb 2026] - It seems it was part of AWS’s rollout plan for rolling out open weight models...]]></summary></entry><entry><title type="html">Ultra Low Bedrock LLM Rate Limits for New AWS Accounts? Time to Wake Up Your Inactive AWS Accounts!</title><link href="https://gabrielkoo.com/blog/ultra-low-bedrock-llm-rate-limits-for-new-aws-accounts-time-to-wake-up-your-inactive-aws-accounts-3no0/" rel="alternate" type="text/html" title="Ultra Low Bedrock LLM Rate Limits for New AWS Accounts? Time to Wake Up Your Inactive AWS Accounts!"/><published>2025-11-26T00:00:00+00:00</published><updated>2025-11-26T00:00:00+00:00</updated><id>https://gabrielkoo.com/blog/ultra-low-bedrock-llm-rate-limits-for-new-aws-accounts-time-to-wake-up-your-inactive-aws-accounts-3no0</id><content type="html" xml:base="https://gabrielkoo.com/blog/ultra-low-bedrock-llm-rate-limits-for-new-aws-accounts-time-to-wake-up-your-inactive-aws-accounts-3no0/"><![CDATA[<h2 id="are-you-struggling-with-amazon-bedrocks-ultra-low-quotas-on-new-aws-accounts-">Are You Struggling With Amazon Bedrock’s Ultra-Low Quotas on New AWS Accounts? 🤯</h2> <p>Are you hitting painfully low rate limits when running LLMs on Amazon Bedrock from a newly created AWS account? You’re definitely not alone — many developers are discovering that new accounts often start with <strong>extremely restrictive quotas</strong>, sometimes as low as <strong>2 requests per minute</strong>.</p> <p>Official guidance usually suggests contacting an account manager to escalate your limits, but for startups, hobby projects, or personal experimentation, that path is far from simple.</p> <h2 id="new-account-big-ambitions-tiny-quota-">New Account? Big Ambitions, Tiny Quota 🤏🚧</h2> <p>Since 2024 or maybe 2025, AWS quietly adjusted Bedrock’s default model access for newly created accounts - together with other lower account defaults such as a maximum concurreny of 10 for AWS Lambda. Even when using global endpoints, many fresh accounts get <strong>just a few requests per minute</strong> (e.g., 2 rpm for Claude 4.5 Sonnet). This severely slows down prototyping or early-stage AI development.</p> <p>Meanwhile, <strong>older AWS accounts</strong> — even ones that never touched Bedrock before — often start with dramatically higher limits, approaching <strong>200+ rpm</strong> for the exact same models.</p> <p><img src="/assets/img/21649e128927.png" alt="Sonnet 4.5 Rates"/></p> <p>This creates a real operational advantage for teams with access to aged accounts.</p> <h2 id="the-elder-account-advantage-️">The Elder Account Advantage 🕰️✨</h2> <p>AWS appears to apply significantly stricter defaults to newer accounts while preserving far more permissive limits for older ones. This aligns with AWS’s long-standing pattern of maintaining stable experiences for long-time customers.</p> <p>In practice, a dormant but years-old AWS account can immediately receive much <strong>higher Bedrock limits</strong> purely due to its age.</p> <p>Below is a striking example: <strong>3 rpm</strong> for new accounts vs <strong>250 rpm</strong> for older accounts on Claude 4.5 Opus.</p> <p><img src="/assets/img/ad1a563932cb.png" alt="Opus 4.5 Rates"/></p> <h2 id="why-aws-is-unlikely-to-reduce-older-accounts-limits-">Why AWS Is Unlikely to Reduce Older Accounts’ Limits 🔒🏢</h2> <p>Reducing quotas for older accounts would create major risk and break expectations for long-standing customers — especially enterprises.</p> <ul> <li>Many organizations have stable, long-lived workloads.</li> <li>Retroactively lowering quotas could break pipelines and violate performance assumptions.</li> <li>AWS historically avoids backward-incompatible changes unless absolutely necessary.</li> </ul> <p>Because of this, it’s unlikely AWS will apply newer, stricter defaults to older accounts. Teams spinning up new AWS accounts face a much steeper ramp for experimenting with Bedrock.</p> <h2 id="finding-and-reusing-older-aws-accounts-">Finding and Reusing Older AWS Accounts 🔎📦</h2> <p>If your organization has older AWS accounts lying around, they may offer instant scaling advantages. With the new <strong><a href="https://aws.amazon.com/about-aws/whats-new/2025/11/aws-organizations-direct-account-transfers/">AWS Organizations Direct Account Transfer</a></strong> feature, accounts can move between Organizations without removing payment methods or performing the old, painful detachment workflow.</p> <p>When moving such accounts, remember to update:</p> <ul> <li><strong>Legal entity name</strong></li> <li><strong>Root user email</strong></li> <li><strong>Addresses and billing contacts</strong></li> <li><strong>Tax information</strong></li> </ul> <p>If your organization uses <a href="https://docs.aws.amazon.com/accounts/latest/reference/using-orgs-trusted-access.html">Trusted Access for AWS Account Management</a>, these updates are straightforward. Also make sure to audit the account for any leftover resources before dedicating it to GenAI workloads.</p> <h2 id="why-new-accounts-why-not-run-everything-in-one-aws-account-">Why New Accounts? Why Not Run Everything in One AWS Account? 🧩💼</h2> <p>Putting Bedrock experiments, different production services, and dev workloads into a single account sounds convenient — until it isn’t. Combining everything into one single AWS Account creates unnecessary risk and operational noise.</p> <ol> <li> <p>Isolation Protects You 🔥🧱 Account boundaries are AWS’s strongest safety net. One bad experiment or IAM mistake shouldn’t touch production. Isolation limits accidental data access, cost spikes, and incident blast radius.</p> </li> <li> <p>Governance Stays Clean 📜✨ Different workloads need different controls. Separate accounts keep audits simpler, give clear ownership, and let you apply SCPs and guardrails without compromise.</p> </li> <li> <p>Costs Stay Transparent 💸📊 Mixing Bedrock prototyping with core services muddies cost reporting. Individual accounts let you track usage, set budgets, and avoid team-to-team disputes.</p> </li> <li> <p>Experiments Move Faster ⚡🔬 A sandbox (ideally an older account with higher limits) lets you test models, tweak IAM, and push boundaries freely — without risking production stability.</p> </li> <li> <p>It Matches AWS Best Practices 🏗️📚 AWS recommends multi-account setups for lifecycle separation and blast-radius control. Using older accounts for Bedrock while keeping core workloads isolated follows this playbook perfectly.</p> </li> </ol> <h2 id="how-to-break-free-from-bedrocks-slow-lane-">How to Break Free From Bedrock’s Slow Lane 🚀💡</h2> <ul> <li>Identify older AWS accounts that haven’t been used recently.</li> <li>Transfer them into your AWS Organization using the streamlined Direct Account Transfer workflow.</li> <li>Update all account metadata for compliance.</li> <li>Deploy your Bedrock workloads — and unlock higher default limits instantly.</li> </ul> <p>This approach helps teams accelerate their AI development journey despite the strict constraints placed on newly created AWS accounts.</p> <p>Have you seen similar quota differences in your environment? Share your experience — more data points help the community understand the pattern! 🙌</p>]]></content><author><name>Gabriel Koo (AWS Community Builder)</name><email>hi@gabrielkoo.com</email></author><summary type="html"><![CDATA[Are You Struggling With Amazon Bedrock’s Ultra-Low Quotas on New AWS Accounts? 🤯 Are you...]]></summary></entry><entry><title type="html">Use OpenAI Codex CLI with Amazon Bedrock Models - Pay As You Go</title><link href="https://gabrielkoo.com/blog/use-openai-codex-cli-with-amazon-bedrock-models-pay-as-you-go-48eb/" rel="alternate" type="text/html" title="Use OpenAI Codex CLI with Amazon Bedrock Models - Pay As You Go"/><published>2025-08-27T00:00:00+00:00</published><updated>2025-08-27T00:00:00+00:00</updated><id>https://gabrielkoo.com/blog/use-openai-codex-cli-with-amazon-bedrock-models-pay-as-you-go-48eb</id><content type="html" xml:base="https://gabrielkoo.com/blog/use-openai-codex-cli-with-amazon-bedrock-models-pay-as-you-go-48eb/"><![CDATA[<p><strong>NEW</strong> (2026 Jan) Newer versions of Codex uses <code class="language-plaintext highlighter-rouge">/v1/responses</code> API by default and dropped support for chat completions endpoint. You’ll need to add <code class="language-plaintext highlighter-rouge">wire_api = "responses"</code> to your existing config to use the new endpoint instead: <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html">https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html</a>.</p> <h2 id="openai-codex-cli-on-amazon-bedrock-models-why-bother">OpenAI Codex CLI on Amazon Bedrock Models: Why Bother?</h2> <p>Here’s why Codex plus the Amazon Bedrock models make sense under some cases:</p> <ol> <li><strong>Pay as you go</strong>: No fixed cost—just pay for Bedrock tokens and Lambda invocations. No monthly minimum while Amazon Q Developer CLI has a free-tier quota, you must upgrade to the $19 USD/month paid plan if you have breached it, which <em>still</em> enforces usage caps.</li> <li><strong>Use your own fine-tuned models</strong>: Swap model endpoints easily; the gateway can even route to your own Amazon Bedrock fine-tunes (e.g. Nova) without friction.</li> <li><strong>Transparent logging</strong>: Codex’s request/response logs give you full visibility — a plus for debugging and cost tracking.</li> <li><strong>No AWS IAM/Identity required - Perfect for Headless Workloads</strong>: You only need your Bedrock Access Gateway API key; no need to log into your AWS identities with an inconvenient console authentication with your AWS Identity Center user/Builder ID (great for CI/CD and ephemeral cloud instances).</li> <li><strong>Regional flexibility</strong>: Yes, you could use <a href="https://docs.anthropic.com/en/docs/claude-code/amazon-bedrock">Claude Code with Amazon Bedrock</a>, but then I live in Hong Kong where Claude model usage is not allowed.</li> <li><strong>Amazon Nova Micro: Price King</strong>: For pure simple text LLM tasks, swapping Sonnet 4 for Nova Micro cuts costs by a factor of 85 — Comparing between Nova Micro and Sonnet 4.</li> <li>If you have a bunch of AWS Credits from AWS events - you’re cover with your usages with <code class="language-plaintext highlighter-rouge">gpt-oss</code> / Nova family of models!</li> </ol> <h2 id="setup-codex-cli--bedrock-gateway">Setup: Codex CLI + Bedrock Gateway</h2> <p>(UPDATE: Deprecated after Codex v0.80.0 https://github.com/openai/codex/discussions/7782)</p> <p>Get your Lambda Gateway Function URL and API Key after deployment. (Check my earlier article for a step-by-step guide to get it running on Lambda via AWS SAM: <a href="https://dev.to/aws-builders/use-amazon-bedrock-models-via-an-openai-api-compatible-serverless-endpoint-now-without-fixed-cost-5hf5">https://dev.to/aws-builders/use-amazon-bedrock-models-via-an-openai-api-compatible-serverless-endpoint-now-without-fixed-cost-5hf5</a>)</p> <p>Here’s a no-brainer if you want to skip my article and deploy it right away:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">(</span>
  <span class="nb">cd</span> /tmp <span class="o">&amp;&amp;</span> <span class="se">\</span>
  git clone <span class="nt">--depth</span><span class="o">=</span>1 https://github.com/gabrielkoo/bedrock-access-  gateway-function-url <span class="o">&amp;&amp;</span> <span class="se">\</span>
  <span class="nb">cd </span>bedrock-access-gateway-function-url <span class="o">&amp;&amp;</span> <span class="se">\</span>
  ./prepare_source.sh <span class="o">&amp;&amp;</span> <span class="se">\</span>
  sam build <span class="o">&amp;&amp;</span> <span class="se">\</span>
  sam deploy <span class="nt">--guided</span>
<span class="o">)</span>
</code></pre></div></div> <p>Now <a href="https://github.com/openai/codex?tab=readme-ov-file#installing-and-running-codex-cli">install Codex</a>:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm i <span class="nt">-g</span> @openai/codex
</code></pre></div></div> <p>Configure Codex like so:</p> <div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ~/.codex/config.toml</span>
<span class="n">profile</span> <span class="o">=</span><span class="w"> </span><span class="s">'bedrock'</span>

<span class="k">[</span><span class="n">profiles</span><span class="k">.</span><span class="n">bedrock</span><span class="k">]</span>
<span class="n">model</span> <span class="o">=</span><span class="w"> </span><span class="s">'openai.gpt-oss-120b-1:0'</span>
<span class="c"># OR</span>
<span class="c"># model = 'us.amazon.nova-premier-v1:0'</span>
<span class="n">model_provider</span> <span class="o">=</span><span class="w"> </span><span class="s">'bedrock'</span>
<span class="n">model_reasoning_effort</span> <span class="o">=</span><span class="w"> </span><span class="s">"low"</span>
<span class="c"># NEW! Newer versions of Codex uses /v1/responses API by default.</span>
<span class="n">wire_api</span> <span class="o">=</span><span class="w"> </span><span class="s">"chat"</span>

<span class="k">[</span><span class="n">model_providers</span><span class="k">.</span><span class="n">bedrock</span><span class="k">]</span>
<span class="n">name</span> <span class="o">=</span><span class="w"> </span><span class="s">'bedrock'</span>
<span class="n">base_url</span> <span class="o">=</span><span class="w"> </span><span class="s">'https://RANDOM_HASH_HERE.lambda-url.AWS_REGION.on.aws/api/v1'</span>
<span class="n">env_key</span> <span class="o">=</span><span class="w"> </span><span class="s">'CODEX_OPENAI_API_KEY'</span>
</code></pre></div></div> <p>Alternatively, if you want to stick to only <code class="language-plaintext highlighter-rouge">gpt-oss</code> models but not e.g. Claude/Nova families of models, you can use the latest official OpenAI compatible endpoint with an Amazon Bedrock API Key instead - there will be no need to host the Bedrock Access Gateway:</p> <div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">...</span>
<span class="n">web_search</span> <span class="o">=</span><span class="w"> </span><span class="s">"disabled"</span>

<span class="k">[</span><span class="n">model_providers</span><span class="k">.</span><span class="n">bedrock</span><span class="k">]</span>
<span class="n">name</span> <span class="o">=</span><span class="w"> </span><span class="s">"AmazonBedrock"</span>
<span class="n">base_url</span> <span class="o">=</span><span class="w"> </span><span class="s">"https://bedrock-mantle.us-west-1.api.aws/v1"</span>
<span class="n">env_key</span> <span class="o">=</span><span class="w"> </span><span class="s">"ENV_KEY_FOR_YOUR_BEDROCK_API_KEY"</span>

<span class="p">...</span>

<span class="k">[</span><span class="n">profiles</span><span class="k">.</span><span class="n">gpt-oss</span><span class="k">]</span>
<span class="c"># NOTE: The model ID is truncated if you use the responses API.</span>
<span class="n">model</span> <span class="o">=</span><span class="w"> </span><span class="s">"openai.gpt-oss-120b"</span>

</code></pre></div></div> <p>Query the LLM:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>codex <span class="nt">--profile</span> bedrock <span class="s2">"What is my public IP address?"</span>
</code></pre></div></div> <p><img src="/assets/img/f87b08972878.png" alt="Codex with Bedrock model in action"/></p> <h2 id="model-support">Model Support</h2> <p>Note that not all Bedrock models work over the gateway. Models must support <strong>tool calls</strong>.</p> <p><strong>GPT OSS (20b/120b)</strong>: Optimzied with Codex <strong>Nova family (Premier, Pro, Lite, Micro):</strong> All tested and working. <strong>Claude, Llama, Mistral, Command R:</strong> Working, subject to regional restrictions (e.g. Hong Kong).</p> <h2 id="amazon-q-developer-cli-vs-codex-cli-on-bedrock">Amazon Q Developer CLI vs Codex CLI on Bedrock</h2> <p>Amazon Q Developer CLI is indeed <strong>officially supported in Hong Kong</strong> — but after your free usage (<a href="https://aws.amazon.com/q/developer/pricing/">50 agentic chats/month</a>), you’ll need the $19/month paid plan, and may hit quotas even then.</p> <p>Codex CLI via Amazon Bedrock gives <em>unmetered usage</em> (subject to whatever quotas you have on Amazon Bedrock itself and Lambda), no AWS login required - you just need to prepare the API key that you defined your self when you deployed the Bedrock Access Gateway.</p> <h2 id="why-dont-i-just-use-the-new-openai-compatible-endpoint">Why Don’t I Just Use the new OpenAI Compatible Endpoint?</h2> <p>Refer to my other blog article <a href="https://dev.to/aws-builders/aws-launches-openai-compatible-api-for-bedrock-and-i-did-some-tests-49cd">AWS Launches OpenAI-Compatible API for Bedrock (and I Did Some Tests!)</a>, the new OpenAI compatible Amazon Bedrock API endpoint supports <code class="language-plaintext highlighter-rouge">gpt-oss</code> 20b as well as 120b out of the box, other models like Nova or Claude are not supported.</p> <p>So with my solution of wrapping the calls via a Bedrock Access Gateway, you can switch to other models whenever you want according your choice.</p> <h2 id="summary">Summary</h2> <p>Codex CLI + Amazon Bedrock (via OpenAI-compatible gateway) gives developers a way to use pay-as-you-go agentic CLI Agents, swap fine-tuned models easily, and avoid region/pricing issues present in other AWS or Anthropic toolings. For minimal cost, Nova Micro is unbeatable for text workloads. And yes, my serverless gateway solution is the backbone — but more about that in <a href="https://dev.to/aws-builders/use-amazon-bedrock-models-via-an-openai-api-compatible-serverless-endpoint-now-without-fixed-cost-5hf5">my previous blog</a>!</p>]]></content><author><name>Gabriel Koo (AWS Community Builder)</name><email>hi@gabrielkoo.com</email></author><summary type="html"><![CDATA[OpenAI Codex CLI on Amazon Bedrock Models: Why Bother? Here’s why Codex plus the Amazon...]]></summary></entry></feed>