<?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: Niko Peltoniemi</title>
    <description>The latest articles on DEV Community by Niko Peltoniemi (@npelto).</description>
    <link>https://dev.to/npelto</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%2F4091562%2F1ec02063-6825-4d5b-9cd9-47f2cb34b7d4.jpg</url>
      <title>DEV Community: Niko Peltoniemi</title>
      <link>https://dev.to/npelto</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/npelto"/>
    <language>en</language>
    <item>
      <title>Listing the models your Laravel AI providers actually offer</title>
      <dc:creator>Niko Peltoniemi</dc:creator>
      <pubDate>Thu, 03 Sep 2026 13:12:47 +0000</pubDate>
      <link>https://dev.to/npelto/listing-the-models-your-laravel-ai-providers-actually-offer-41n9</link>
      <guid>https://dev.to/npelto/listing-the-models-your-laravel-ai-providers-actually-offer-41n9</guid>
      <description>&lt;p&gt;&lt;em&gt;The Laravel AI SDK configures providers but never tells you which models those credentials can call, so model IDs end up hardcoded and drift. lmsomeco/laravel-ai-models reads config/ai.php, fetches each provider's live model list, and normalizes the results into one cached, filterable DTO. It also provides an honest distinction between capabilities that are known and capabilities that are simply unreported.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The catalog problem nobody configures for
&lt;/h2&gt;

&lt;p&gt;The Laravel AI SDK (laravel/ai) gives you a tidy way to declare providers in config/ai.php and address them through the Laravel\Ai\Enums\Lab enum. What it does not give you is an answer to the question that shows up the moment an application has more than one provider wired in: which models can I actually call right now, with these keys?&lt;/p&gt;

&lt;p&gt;That answer usually ends up hardcoded: a model ID in a config array, or a select box populated from a list someone updated by hand a few releases ago. It drifts. Providers retire snapshots, ship new families, and change context windows on their own schedule, and the application never notices until a request comes back with a 404.&lt;/p&gt;

&lt;p&gt;Every major provider does expose a listing endpoint. The trouble is that no two of them agree on anything: OpenAI-style providers return &lt;code&gt;{\"data\": [{\"id\": \"...\"}]}&lt;/code&gt; behind a Bearer token, Anthropic uses &lt;code&gt;x-api-key&lt;/code&gt; plus an &lt;code&gt;anthropic-version&lt;/code&gt; header and paginates with &lt;code&gt;has_more/after_id&lt;/code&gt;, OpenRouter returns rich metadata from a public endpoint that needs no key at all, and Gemini wants the key in the query string with model IDs prefixed models/. Writing that adapter layer once per project is exactly the kind of work that never quite gets finished.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lmsomeco/laravel-ai-models&lt;/code&gt; is a small companion package that does it for you: it lists and caches the currently-available models for each provider you already have configured, and normalizes them into one shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and run
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require lmsomeco/laravel-ai-models
php artisan ai:models
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no second set of credentials to manage. The package reads config/ai.php, the SDK's own config, and keys its resolver map on Lab enum values, so any provider entry whose driver matches a Lab case with a registered resolver is picked up automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/ai.php&lt;/span&gt;
&lt;span class="s1"&gt;'providers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'openai'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'openai'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'OPENAI_API_KEY'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A provider that is declared but has no key is simply skipped. Nothing throws for being unconfigured, and no environment variable is required to get started. The package's own config file is merged automatically whether or not you publish it.&lt;/p&gt;

&lt;p&gt;The command takes an optional provider argument and two options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan ai:models                 &lt;span class="c"&gt;# every configured provider&lt;/span&gt;
php artisan ai:models anthropic       &lt;span class="c"&gt;# one provider&lt;/span&gt;
php artisan ai:models &lt;span class="nt"&gt;--refresh&lt;/span&gt;       &lt;span class="c"&gt;# bypass the cache&lt;/span&gt;
php artisan ai:models openrouter &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One shape for every provider
&lt;/h2&gt;

&lt;p&gt;Everything comes back as a collection of LmSomeco\AiModels\Data\AiModel, a readonly value object:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;provider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Lab&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The lab that served this model.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;string&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The provider's model ID.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;?string&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Display name, when the provider supplies one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contextWindow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;?int&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only where the provider exposes it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;maxOutputTokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;?int&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;modalities&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list&amp;lt;string&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Input modalities. Empty when unknown.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;outputModalities&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list&amp;lt;string&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Output modalities. Empty when unknown.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;createdAt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;?DateTimeImmutable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When the provider reports it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;raw&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;array&amp;lt;string, mixed&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The untouched provider payload.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The facade is a thin proxy over a &lt;code&gt;ModelRegistry&lt;/code&gt; singleton, which is equally resolvable through constructor injection if you would rather not use facades:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\Ai&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\Enums&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\Lab&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;LmSomeco&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\AiModels&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\Facades&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\AiModels&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;AiModels&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                       &lt;span class="c1"&gt;// Collection&amp;lt;AiModel&amp;gt; across every configured provider&lt;/span&gt;
&lt;span class="nc"&gt;AiModels&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// one provider, accepts a Lab or its string value&lt;/span&gt;
&lt;span class="nc"&gt;AiModels&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;configuredProviders&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// names of providers that have the credentials they need&lt;/span&gt;
&lt;span class="nc"&gt;AiModels&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Groq&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// drop one provider's cached list&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because these are ordinary Laravel collections, filtering needs no special API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AiModels&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Lab&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;firstWhere&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;raw&lt;/code&gt; is deliberately kept on the DTO but dropped from &lt;code&gt;toArray()&lt;/code&gt;, so &lt;code&gt;--json&lt;/code&gt; output and API responses stay compact while provider-specific fields (pricing, per-provider limits, anything the normalizer does not model) remain reachable in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modalities: known, and honestly unknown
&lt;/h2&gt;

&lt;p&gt;This is the part worth reading closely, because it is where a normalizing layer can quietly lie to you.&lt;/p&gt;

&lt;p&gt;An empty modality array means &lt;strong&gt;unknown&lt;/strong&gt;, not "no". The distinction matters because the live sources of capability data are not equally good:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenRouter&lt;/strong&gt; returns &lt;code&gt;architecture.input_modalities&lt;/code&gt; and &lt;code&gt;architecture.output_modalities&lt;/code&gt; directly in its listing response. This is real data from the provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic&lt;/strong&gt; supplies modality, context-window and output-token metadata in its current model-list response. Older payloads fall back to Anthropic's documented text/image capabilities for current Claude models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI&lt;/strong&gt; returns no capability fields at all. &lt;code&gt;OpenAiResolver&lt;/code&gt; enriches the sparse response by matching official model families, including snapshots and fine-tuned IDs, against their documented modalities. Unrecognized custom IDs stay empty rather than being guessed at.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other OpenAI-compatible providers (Groq, Mistral, DeepSeek, xAI) go through the base resolver, which maps &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;created&lt;/code&gt; and leaves capabilities empty, because those endpoints do not report them.&lt;/p&gt;

&lt;p&gt;Two helpers, &lt;code&gt;accepts()&lt;/code&gt; and &lt;code&gt;generates()&lt;/code&gt;, make capability checks readable, and the difference between a strict and a permissive filter explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;LmSomeco&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\AiModels&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\Data&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\AiModel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Strict: text-out only, and excludes anything whose capabilities are unknown.&lt;/span&gt;
&lt;span class="nv"&gt;$textOnly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AiModels&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;AiModel&lt;/span&gt; &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;outputModalities&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'text'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Permissive: keeps unknowns, excludes only models known to generate image or audio.&lt;/span&gt;
&lt;span class="nv"&gt;$probablyTextOnly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AiModels&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;AiModel&lt;/span&gt; &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'audio'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick the one that matches your risk. The strict form hides models until a resolver knows their capabilities; the permissive form will occasionally let one through that you did not expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching, because these are HTTP calls
&lt;/h2&gt;

&lt;p&gt;Model lists are fetched over HTTP, so they are cached per provider rather than re-requested on every call. Keys are &lt;code&gt;{prefix}:{provider-name}&lt;/code&gt; (default prefix &lt;code&gt;ai-models&lt;/code&gt;, so &lt;code&gt;ai-models:openai&lt;/code&gt;), with &lt;code&gt;AI_MODELS_CACHE_STORE&lt;/code&gt; selecting the store and &lt;code&gt;AI_MODELS_CACHE_TTL&lt;/code&gt; the lifetime: 3600 seconds by default, or &lt;code&gt;null&lt;/code&gt; to cache forever and bust manually.&lt;/p&gt;

&lt;p&gt;Busting is available at every level: &lt;code&gt;php artisan ai:models --refresh&lt;/code&gt; from the CLI, &lt;code&gt;AiModels::refresh()&lt;/code&gt; for every declared provider, &lt;code&gt;AiModels::refresh('openai')&lt;/code&gt; for one, or a &lt;code&gt;$fresh = true&lt;/code&gt; argument on &lt;code&gt;all()&lt;/code&gt;, &lt;code&gt;provider()&lt;/code&gt; and &lt;code&gt;driver()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two details in the cache layer are worth knowing about, because both are the kind of thing you would otherwise debug in production:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unusable entries are discarded, not fatal.&lt;/strong&gt; A cache entry holding objects whose class this deployment can no longer load unserializes as &lt;code&gt;__PHP_Incomplete_Class&lt;/code&gt;. Rather than throwing a &lt;code&gt;TypeError&lt;/code&gt;, the registry treats such a payload as a miss, forgets it, and refetches. The same check covers collections cached by an older release that predate a newly normalized property. Those are refetched too, so an upgrade does not serve you a DTO with a missing field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A refetch that fails does not leave stale data behind.&lt;/strong&gt; The unusable entry is forgotten &lt;em&gt;before&lt;/em&gt; the refetch runs, so a broken payload cannot outlive a refetch that throws.&lt;/p&gt;

&lt;p&gt;That failure is worth planning for. Resolvers call &lt;code&gt;-&amp;gt;throw()&lt;/code&gt; on the HTTP response, so a provider outage or a revoked key surfaces as an &lt;code&gt;Illuminate\\Http\\Client\\RequestException&lt;/code&gt; out of &lt;code&gt;models()&lt;/code&gt;, not as an empty collection. If you are rendering a model picker, catch it; the default 15-second timeout (&lt;code&gt;AI_MODELS_TIMEOUT&lt;/code&gt;) bounds how long you wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage is partial, and the gaps are documented
&lt;/h2&gt;

&lt;p&gt;Seven providers are live today:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider (&lt;code&gt;Lab&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Resolver&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OpenAI&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OpenAiResolver&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Catalog-enriched modalities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Groq&lt;/code&gt;, &lt;code&gt;Mistral&lt;/code&gt;, &lt;code&gt;DeepSeek&lt;/code&gt;, &lt;code&gt;xAI&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OpenAiCompatibleResolver&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Differ only by base URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OpenRouter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OpenRouterResolver&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rich metadata, no key required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Anthropic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AnthropicResolver&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;x-api-key&lt;/code&gt; auth, paginated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eight more are mapped to a &lt;code&gt;Lab&lt;/code&gt; in &lt;code&gt;laravel/ai&lt;/code&gt; but have no resolver yet: Gemini, Ollama, Azure, Cohere, ElevenLabs, Bedrock, Jina and VoyageAI. Each needs bespoke work for a specific reason, and the package config documents which: Ollama uses &lt;code&gt;GET /api/tags&lt;/code&gt; with a different JSON shape entirely, Azure is deployment-based with an &lt;code&gt;api-version&lt;/code&gt; query parameter, Bedrock needs AWS SigV4 or bearer-token auth, and Jina and VoyageAI have no list endpoint at all, so they would need a static catalog rather than a live fetch.&lt;/p&gt;

&lt;p&gt;If your stack is Gemini-first or Ollama-first, this package does not help you yet. That is a real limitation, not a roadmap detail to gloss over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-tenant keys: database connectors
&lt;/h2&gt;

&lt;p&gt;The default assumption is that providers live in &lt;code&gt;config/ai.php&lt;/code&gt;. If you need runtime-editable configuration (per-tenant credentials, an admin UI, customers bringing their own keys), there is an optional database-backed layer behind &lt;code&gt;AI_MODELS_CONNECTORS=true&lt;/code&gt; and a publishable migration.&lt;/p&gt;

&lt;p&gt;The useful piece is what &lt;code&gt;ConnectorManager::configure()&lt;/code&gt; returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$providerKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$connectorManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$connector&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 'db-{id}'&lt;/span&gt;

&lt;span class="nv"&gt;$models&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AiModels&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$providerKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Laravel&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\Ai&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\Facades&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\Ai&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$providerKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It registers a runtime &lt;code&gt;config('ai.providers.db-{id}')&lt;/code&gt; entry and hands back the key, which works anywhere a provider name is expected, including in &lt;code&gt;laravel/ai&lt;/code&gt; itself. Because resolvers are selected by the entry's &lt;code&gt;driver&lt;/code&gt; rather than by its name, a runtime-injected provider picks up the right resolver with no extra registration. The mutation is process-local: credentials are decrypted from the database per request, never written to a &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;The shipped &lt;code&gt;ai_connectors&lt;/code&gt; table stores &lt;code&gt;api_key&lt;/code&gt; with Laravel's &lt;code&gt;encrypted&lt;/code&gt; cast, so it is encrypted at rest via &lt;code&gt;APP_KEY&lt;/code&gt;. Nothing in the package depends on the concrete Eloquent model, though. Everything is typed against a &lt;code&gt;Connector&lt;/code&gt; contract of four getters and two static finders. Your own model can satisfy it by extending the shipped one, by using the &lt;code&gt;IsConnector&lt;/code&gt; trait when your columns match the standard layout, or by implementing the six methods against a completely different schema. A misconfigured &lt;code&gt;connectors.model&lt;/code&gt; throws an &lt;code&gt;InvalidArgumentException&lt;/code&gt; naming the offending class when &lt;code&gt;ConnectorManager&lt;/code&gt; is first resolved, rather than failing obscurely later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a provider
&lt;/h2&gt;

&lt;p&gt;The extension point is a three-method interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ProviderResolver&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Lab&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;configured&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/** @return Collection&amp;lt;int, AiModel&amp;gt; */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Collection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your provider speaks OpenAI's dialect (&lt;code&gt;GET /models&lt;/code&gt; returning &lt;code&gt;{\"data\": [...]}&lt;/code&gt; with Bearer auth), extend &lt;code&gt;OpenAiCompatibleResolver&lt;/code&gt; and override only what differs, usually &lt;code&gt;mapModel()&lt;/code&gt; to pull in extra fields. &lt;code&gt;OpenRouterResolver&lt;/code&gt; is the worked example: it overrides &lt;code&gt;configured()&lt;/code&gt; because the endpoint needs no key, and &lt;code&gt;mapModel()&lt;/code&gt; to read &lt;code&gt;context_length&lt;/code&gt;, &lt;code&gt;top_provider.max_completion_tokens&lt;/code&gt; and the architecture modalities.&lt;/p&gt;

&lt;p&gt;Register it by mapping a &lt;code&gt;Lab&lt;/code&gt; value in &lt;code&gt;config/ai-models.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'resolvers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Lab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Gemini&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\App&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\Ai&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\Resolvers&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nc"&gt;\GeminiResolver&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resolvers are built through the container, so any constructor dependency you need is injected. Config merging is unambiguous in one direction: the resolver's own entry supplies defaults, your app's &lt;code&gt;config/ai.php&lt;/code&gt; entry is merged on top, and &lt;code&gt;timeout&lt;/code&gt; is filled in last. &lt;strong&gt;Your application's config always wins.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The package solves one problem and stops there: turning "which models do my configured providers offer" into a cached collection of normalized DTOs, without duplicating a single credential. &lt;code&gt;php artisan ai:models&lt;/code&gt; is the whole onboarding story.&lt;/p&gt;

&lt;p&gt;The constraints are worth restating plainly. It requires PHP 8.3+, &lt;code&gt;laravel/ai&lt;/code&gt; &lt;code&gt;^0.7&lt;/code&gt; through &lt;code&gt;^0.10&lt;/code&gt;, and Laravel 12 or 13 components. Seven providers have resolvers; eight more need contributions. Capability metadata is only as good as what each provider publishes, and the package prefers an empty array to a confident guess, which means your filters have to decide explicitly how to treat unknowns. And because model lists are live HTTP calls, a provider outage reaches your code as an exception rather than a silent empty list.&lt;/p&gt;

&lt;p&gt;For an application already running on &lt;code&gt;laravel/ai&lt;/code&gt; that needs to stop hardcoding model IDs, that is a reasonable trade.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>laravel</category>
      <category>php</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
