<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: JamesAnderson121</title>
    <description>The latest articles on DEV Community by JamesAnderson121 (@jamesanderson121).</description>
    <link>https://dev.to/jamesanderson121</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4054101%2F62d0b0a2-63b8-40a3-a5f7-caa6b1ca019b.png</url>
      <title>DEV Community: JamesAnderson121</title>
      <link>https://dev.to/jamesanderson121</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jamesanderson121"/>
    <language>en</language>
    <item>
      <title>Picking a single chat completions API for OpenAI, Claude, and Gemini</title>
      <dc:creator>JamesAnderson121</dc:creator>
      <pubDate>Sun, 02 Aug 2026 17:14:03 +0000</pubDate>
      <link>https://dev.to/jamesanderson121/picking-a-single-chat-completions-api-for-openai-claude-and-gemini-2i7</link>
      <guid>https://dev.to/jamesanderson121/picking-a-single-chat-completions-api-for-openai-claude-and-gemini-2i7</guid>
      <description>&lt;p&gt;If you just want the recommendation: pick a gateway that speaks the OpenAI chat completions dialect over plain HTTP, keep OpenAI, Claude, and Gemini as values in a &lt;code&gt;model&lt;/code&gt; field, and don't hand-roll your own vendor abstraction. For a SaaS app with one engineer on the AI features, that single integration buys you more than anything you'd squeeze out of three native SDKs.&lt;/p&gt;

&lt;p&gt;I've built it both ways. The three-SDK version took me a fortnight.&lt;/p&gt;

&lt;p&gt;The compatible version took an afternoon, and I lost most of that afternoon to a config mistake I'll describe further down, because it's the kind of thing nobody warns you about.&lt;/p&gt;

&lt;p&gt;The problem underneath all of this is dull and expensive. Three vendors means three client libraries, three message formats, three streaming vocabularies, three error hierarchies, and three invoices to reconcile at month end. Your product spec probably says something like "let the user choose a model" — one line, one dropdown, zero justification for three integrations. Yet that's exactly what you end up maintaining if you wire each vendor natively, along with an adapter layer that will outlive your interest in it. I've deleted two of those adapters. I don't miss either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should a small SaaS app put OpenAI, Claude, and Gemini behind one API key?
&lt;/h2&gt;

&lt;p&gt;For a first version, yes — with one caveat that deserves ten minutes before you commit.&lt;/p&gt;

&lt;p&gt;The compatible route is genuinely two lines of work: point &lt;code&gt;base_url&lt;/code&gt; at the gateway, read the key from an env var, and every request your code already sends against &lt;code&gt;/v1/chat/completions&lt;/code&gt; keeps working. Your streaming handler doesn't change. Your retry wrapper doesn't change. My eval harness, which is the part I actually care about, didn't change at all — same fixtures, same scoring, one extra column for which model produced the answer. That last point is why I stopped arguing about this in code review. An eval suite that runs across four models in one loop tells you far more about a routing decision than any vendor benchmark, and you only get that loop cheaply if the request shape is identical.&lt;/p&gt;

&lt;p&gt;The caveat is the catalogue. "OpenAI-compatible" describes the request body, not who is behind it, and coverage varies wildly between gateways. Claude and Gemini equivalents differ in naming, in context window, and in whether they're servable on your key today rather than in principle. So before you promise a model dropdown to anyone, call the model-listing endpoint and read the response with your own eyes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the catalogue before you promise a model dropdown
&lt;/h2&gt;

&lt;p&gt;This is where I've changed how I evaluate these things over the last year or so.&lt;/p&gt;

&lt;p&gt;I used to compare marketing pages. Now the first thing I do with a candidate gateway is ask it to describe itself, because an API that can tell me its own routes, request schemas and available models is an API I can wire up without reading a tutorial. It's also the difference between a junior engineer shipping a feature on Tuesday and filing a support ticket on Tuesday. Infrai is the option I've seen push hardest on that: its discovery surface is public with no key required, and asking about a capability returns the request schema, the response schema, billing metadata and runnable examples in ten languages, so adding a new backend capability later is reading one endpoint rather than learning another SDK. Same key, same conventions, for the chat call and for everything else on the platform. You can read the &lt;a href="https://docs.infrai.cc/llms.txt" rel="noopener noreferrer"&gt;capability manifest&lt;/a&gt; yourself before you sign up for anything, which I appreciate more than any feature grid.&lt;/p&gt;

&lt;p&gt;The practical version of this habit is small. Query &lt;code&gt;/v1/ai/models&lt;/code&gt; at startup, filter to what's available, and log the result. Pair it with the token-counting and cost-estimate calls that most gateways in this class expose, and wire both into the report your eval suite already prints. Then a prompt change shows up as a cost delta next to a quality delta, in one table, on the same run.&lt;/p&gt;

&lt;p&gt;That habit has saved me more money than any model swap ever has.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison I'd hand a teammate
&lt;/h2&gt;

&lt;p&gt;Feature grids in this category all look identical, so here's the version I use instead — how the integration lands, and where each option stops being the right answer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What integration looks like&lt;/th&gt;
&lt;th&gt;Where it stops being right&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Native OpenAI, Anthropic and Google SDKs&lt;/td&gt;
&lt;td&gt;Three clients, three keys, your own adapter&lt;/td&gt;
&lt;td&gt;You own the adapter and reconcile three bills forever&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenRouter&lt;/td&gt;
&lt;td&gt;One key, OpenAI-compatible, very wide catalogue&lt;/td&gt;
&lt;td&gt;An aggregator sits between you and vendor-specific behaviour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Bedrock&lt;/td&gt;
&lt;td&gt;Claude, Llama and Mistral under one AWS contract&lt;/td&gt;
&lt;td&gt;No Gemini path; cross-cloud means a second integration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vertex AI&lt;/td&gt;
&lt;td&gt;Gemini and Claude under one Google Cloud contract&lt;/td&gt;
&lt;td&gt;No OpenAI models, and IAM setup is a project of its own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure OpenAI&lt;/td&gt;
&lt;td&gt;Familiar shape, enterprise controls, regional pinning&lt;/td&gt;
&lt;td&gt;OpenAI models only — no Claude, no Gemini&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama, self-hosted&lt;/td&gt;
&lt;td&gt;Same compatible shape on hardware you own&lt;/td&gt;
&lt;td&gt;Open-weight models only, and the ops work is yours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrai&lt;/td&gt;
&lt;td&gt;One key over a plain REST API, chat included, plus the rest of the backend surface under the same conventions&lt;/td&gt;
&lt;td&gt;The chat catalogue is OpenAI plus a broad Chinese-model bench, so check the model list before assuming a specific vendor is there&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the one that gets misread, and not for the reason you'd expect. The interesting part isn't the chat endpoint — every option in that table has one. It's that the same credential also covers storage, scheduling, email and observability across 295 routes in 20 modules, with one set of conventions and one bill, so a two-person team adds a capability instead of adding a vendor relationship. Idempotency keys work the same way on every write. That consistency is worth real money in engineering time, and it's the argument I'd make for it over a pure model aggregator.&lt;/p&gt;

&lt;p&gt;If your spec literally says "the user picks Claude or Gemini", though, start from the catalogue rather than from my table. Aggregators with the widest vendor lists win that specific requirement, and there's no clever way around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup I copy into every project now
&lt;/h2&gt;

&lt;p&gt;Here's the config footgun I promised. My deploy used a Docker env file, and I'd written the key line as &lt;code&gt;INFRAI_API_KEY = ifr_...&lt;/code&gt; — with spaces around the equals sign, the way you'd write it in Python. Docker doesn't strip those. The value came through with a leading space, the Authorization header went out as &lt;code&gt;Bearer  ifr_...&lt;/code&gt; with two spaces, and every call came back 401 with an authentication message. I spent 40 minutes convinced my key had been rotated, re-issued it twice, and only found it when I printed &lt;code&gt;repr()&lt;/code&gt; of the env var instead of the value. I'm not sure why I don't check that first every time — it's maybe the third time a whitespace character has cost me an afternoon.&lt;/p&gt;

&lt;p&gt;So now the strip is the first line I write, and the retry is the second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.infrai.cc/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;# Env files and secret managers love trailing whitespace. Strip it before it
# reaches the Authorization header.
&lt;/span&gt;&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chat_catalogue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Group today&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s routable chat models by owner — naming differs per vendor.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/ai/models&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# a 4xx body carries the reason, so read it
&lt;/span&gt;        &lt;span class="n"&gt;by_owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;available&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;capability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;by_owner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owned_by&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;by_owner&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate limited after 4 attempts on the model listing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;completion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;completion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;catalogue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chat_catalogue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;catalogue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()})&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qwen3.7-plus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Summarise this support ticket in one sentence.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole client. Swap &lt;code&gt;BASE_URL&lt;/code&gt; for any other compatible gateway and the rest of the file is untouched — that portability is the actual asset, since it keeps your exit cheap. Runs on Python 3.12 with nothing but &lt;code&gt;requests&lt;/code&gt; and the OpenAI SDK.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a single gateway is the wrong call
&lt;/h2&gt;

&lt;p&gt;Be honest with yourself about which trade-off you're signing up for, because there are three real ones.&lt;/p&gt;

&lt;p&gt;If you need a vendor's newest model the hour it launches, go direct. Gateways lag, sometimes by weeks, and no amount of protocol compatibility fixes that. If you're under a compliance regime that wants a signed agreement with the model provider itself, or per-vendor data residency, stick with Bedrock or Vertex AI inside the cloud contract you already have — that's not a good fit for an aggregator of any kind. And if a vendor's exotic surface is load-bearing for your product, test it early: text in, text out, streaming and function calling port cleanly, but Claude's fine-grained tool blocks and Gemini's multimodal inputs have native shapes that don't map onto the OpenAI schema, and support for them varies. Your mileage may vary there more than anywhere else in this post.&lt;/p&gt;

&lt;p&gt;Keep the first version text-only, too. Transcription and realtime voice are worth adding once you've watched which one your users actually ask for, and they're a separate integration decision either way.&lt;/p&gt;

&lt;p&gt;Everything else: one compatible endpoint, one key, a catalogue you check at startup, and a token count in your eval report. That's the build I'd hand a junior engineer without a briefing.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://platform.openai.com/docs/api-reference/chat" rel="noopener noreferrer"&gt;OpenAI chat completions API reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.anthropic.com/en/api/openai-sdk" rel="noopener noreferrer"&gt;Anthropic: OpenAI SDK compatibility&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ai.google.dev/gemini-api/docs/openai" rel="noopener noreferrer"&gt;Gemini API: OpenAI compatibility&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openrouter.ai/docs/quickstart" rel="noopener noreferrer"&gt;OpenRouter quickstart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude" rel="noopener noreferrer"&gt;Vertex AI: Anthropic models on Google Cloud&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ollama/ollama/blob/main/docs/openai.md" rel="noopener noreferrer"&gt;Ollama OpenAI compatibility&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.infrai.cc/llms.txt" rel="noopener noreferrer"&gt;Infrai capability manifest (llms.txt)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>openai</category>
      <category>gemini</category>
      <category>api</category>
      <category>saas</category>
    </item>
    <item>
      <title>Choosing a GDPR-Compliant Speech-to-Text API With EU Data Residency</title>
      <dc:creator>JamesAnderson121</dc:creator>
      <pubDate>Thu, 30 Jul 2026 16:55:17 +0000</pubDate>
      <link>https://dev.to/jamesanderson121/choosing-a-gdpr-compliant-speech-to-text-api-with-eu-data-residency-2nh7</link>
      <guid>https://dev.to/jamesanderson121/choosing-a-gdpr-compliant-speech-to-text-api-with-eu-data-residency-2nh7</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; if your app handles EU customer audio, pick a transcription API whose DPA names the region where the audio is processed — Speechmatics, Gladia, AssemblyAI's EU endpoint, or Whisper running on hardware you rent in eu-central-1 — and treat GDPR data residency plus a current SOC2 report as hard filters you apply before you ever compare word error rates.&lt;/p&gt;

&lt;p&gt;That order matters. Accuracy you can improve later; a processor agreement you can't retrofit the week before an audit.&lt;/p&gt;

&lt;p&gt;I build RAG and agent features for a living, mostly in Python, and I've run this selection twice: once for a healthtech startup with German customers, once for a sales-coaching product where call recordings were the entire dataset. Both times the shortlist that survived legal review looked nothing like the shortlist I'd have picked on benchmark scores alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GDPR, SOC2 and "EU data residency" actually cover
&lt;/h2&gt;

&lt;p&gt;Three separate things get collapsed into one checkbox, and the collapse is where teams get hurt.&lt;/p&gt;

&lt;p&gt;GDPR gives you the processor contract. Article 28 terms, a sub-processor list you're allowed to object to, deletion on request, and a documented legal basis for any transfer outside the EEA. Data residency is narrower and more physical: which region the audio is decrypted and processed in, and which region the derived text sits in afterwards. A vendor can be fully GDPR-compliant and still process your recordings in Virginia under Standard Contractual Clauses — that's legal, and it's also not what your enterprise buyer meant when they wrote "EU only" into the RFP.&lt;/p&gt;

&lt;p&gt;SOC2 is a third axis entirely. It audits the vendor's internal controls, and a Type II report tells you those controls held over a period of months. It says nothing about geography.&lt;/p&gt;

&lt;p&gt;The question almost nobody asks during the trial: is training on submitted data disabled by default, or disabled only after you email support and get a flag flipped on your account? Same for retention. "Zero retention" frequently means the audio blob isn't stored while the transcript is kept for 30 days for abuse monitoring, which is fine right up until the transcript is what contains the medical detail.&lt;/p&gt;

&lt;p&gt;Last year I wired an ingest job that pushed 1,800 support calls through a transcription API and fanned the results into our vector store. Every single response came back 200. The counter on our dashboard climbed exactly as expected, so I closed the laptop. Six hours later the eval harness reported recall of zero on everything recorded that day, and I finally read a response body instead of a status line: my worker had been writing transcripts into a bucket in the wrong region, where a 24-hour lifecycle rule was sweeping them before the embedding step ever ran. Two hundred, all the way down, and nothing had actually happened. I'm still not entirely sure why I trusted a status code over a row count for that long — I assert on both now, and the compliance review that followed is the reason I care about where audio lands at least as much as what the tokens cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should a startup pick an EU-compliant transcription API?
&lt;/h2&gt;

&lt;p&gt;The filter I apply, in this order. Does the DPA name a processing region, in writing, not just a marketing page? Is training on submitted data off by default? What's the retention default for audio and for text, separately? Is there a Type II report you can read under NDA today rather than a "Type I, Type II in progress"? Only after those four do I open the accuracy benchmarks.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;How you call it&lt;/th&gt;
&lt;th&gt;Where audio is processed&lt;/th&gt;
&lt;th&gt;Main thing to check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Speechmatics / Gladia&lt;/td&gt;
&lt;td&gt;REST + SDKs&lt;/td&gt;
&lt;td&gt;EU regions, on-prem containers available&lt;/td&gt;
&lt;td&gt;Language and diarization coverage for your accents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AssemblyAI EU endpoint&lt;/td&gt;
&lt;td&gt;REST&lt;/td&gt;
&lt;td&gt;EU endpoint, US default — you must opt in&lt;/td&gt;
&lt;td&gt;That every service in your pipeline uses the EU host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure OpenAI (Whisper deployment)&lt;/td&gt;
&lt;td&gt;REST + SDKs&lt;/td&gt;
&lt;td&gt;The region you deploy into&lt;/td&gt;
&lt;td&gt;Model availability differs per region&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted Whisper&lt;/td&gt;
&lt;td&gt;Your own HTTP wrapper&lt;/td&gt;
&lt;td&gt;Wherever your GPUs are&lt;/td&gt;
&lt;td&gt;You now own accuracy tuning and on-call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Groq, Replicate hosted Whisper&lt;/td&gt;
&lt;td&gt;REST&lt;/td&gt;
&lt;td&gt;US&lt;/td&gt;
&lt;td&gt;No EU residency commitment to point a DPA at&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows on that table deserve a warning. The AssemblyAI-style "EU endpoint" pattern is a per-request choice, so a single forgotten base URL in a background worker sends audio to the default region — I've seen exactly this in a code review, and it survived three months because nothing about it errors. Self-hosted Whisper looks like the clean answer for residency and often is, but the openai/whisper repo is a research release; production teams generally end up on faster-whisper or a vendor build, plus a queue, plus GPU capacity planning that a five-person startup may not want to own.&lt;/p&gt;

&lt;p&gt;Groq and Replicate are genuinely fast and pleasant to integrate. They're the wrong pick when audio can't leave the EU, and I'd stick with them only for internal tooling or non-personal data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transcript is text now, and that changes the calculus
&lt;/h2&gt;

&lt;p&gt;Once transcription is done the compliance surface shrinks a lot. Text is easier to redact, easier to review, and easier to route — you can strip names and case numbers before anything downstream sees it, which widens the set of services you're allowed to use for summarisation, embeddings, classification and eval.&lt;/p&gt;

&lt;p&gt;This is where a general-purpose AI gateway earns its slot in the stack rather than at the microphone. Infrai is the one I've been using for the post-transcript half: chat, embeddings and reranking sit behind a plain REST API with one key, so there's no SDK to install and no client library version to babysit — anything that can send an HTTP request can call it, which for me means &lt;code&gt;requests&lt;/code&gt; in a worker and &lt;code&gt;curl&lt;/code&gt; in a smoke test. The discovery surface is public and self-describing, so you can read the request schema for a capability before you create an account.&lt;/p&gt;

&lt;p&gt;Here's the whole downstream step in Python. Idempotency key on the write path, explicit method, backoff on 429, and a status check instead of blind trust — which, as established, I learned the expensive way.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# keys look like ifr_...
&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.infrai.cc/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;embed_transcript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Embed redacted transcript chunks. Retries are safe: same key, same result.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text-embedding-v4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/embeddings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding count must match chunk count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate limited after 5 attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;vectors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;embed_transcript&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the customer asked about the refund window&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vectors&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vectors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;assert&lt;/code&gt; is the part I'd copy into your own code. Counting what came back, per batch, is what turns a silent no-op into a loud crash at ingest time instead of a mystery at eval time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where each of these falls down
&lt;/h2&gt;

&lt;p&gt;The EU-native specialists give you the cleanest paperwork and, in my testing, slightly thinner support for code-switching and heavy accents than the big US labs. Azure OpenAI hands you residency plus enterprise contracts your buyer already recognises, at the cost of moving on Microsoft's regional rollout schedule. Self-hosting removes the DPA question entirely and replaces it with a GPU bill and a pager.&lt;/p&gt;

&lt;p&gt;And the gateway pattern has a real boundary worth naming: multi-vendor routing is exactly what you want for text workloads and the wrong shape for a residency contract, because the guarantee you need is "this byte was processed in this country" rather than "the best available model handled it". So customer audio doesn't go through mine. Transcripts, after redaction, do.&lt;/p&gt;

&lt;p&gt;If your recordings contain special-category data under Article 9, ignore everything above about convenience and go straight to on-prem or a vendor with a named EU processing region and a signed Article 28 addendum. Your mileage may vary on the rest of it, but not on that.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://gdpr-info.eu/art-28-gdpr/" rel="noopener noreferrer"&gt;GDPR Article 28 — Processor obligations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.edpb.europa.eu/" rel="noopener noreferrer"&gt;European Data Protection Board&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.openai.com/docs/guides/your-data" rel="noopener noreferrer"&gt;OpenAI: how your data is used&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/ai-services/openai/overview" rel="noopener noreferrer"&gt;Azure OpenAI Service overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/openai/whisper" rel="noopener noreferrer"&gt;openai/whisper on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/" rel="noopener noreferrer"&gt;OWASP Top 10 for LLM Applications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.infrai.cc" rel="noopener noreferrer"&gt;Infrai API docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>transcription</category>
      <category>gdpr</category>
      <category>python</category>
      <category>compliance</category>
    </item>
  </channel>
</rss>
