The catalog problem nobody configures for
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?
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.
Every major provider does expose a listing endpoint. The trouble is that no two of them agree on anything: OpenAI-style providers return {\"data\": [{\"id\": \"...\"}]} behind a Bearer token, Anthropic uses x-api-key plus an anthropic-version header and paginates with has_more/after_id, 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.
lmsomeco/laravel-ai-models 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.
Install and run
composer require lmsomeco/laravel-ai-models
php artisan ai:models
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:
// config/ai.php
'providers' => [
'openai' => [
'driver' => 'openai',
'key' => env('OPENAI_API_KEY'),
],
],
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.
The command takes an optional provider argument and two options:
php artisan ai:models # every configured provider
php artisan ai:models anthropic # one provider
php artisan ai:models --refresh # bypass the cache
php artisan ai:models openrouter --json
One shape for every provider
Everything comes back as a collection of LmSomeco\\AiModels\\Data\\AiModel, a readonly value object:
| Property | Type | Notes |
|---|---|---|
provider |
Lab |
The lab that served this model. |
id |
string |
The provider's model ID. |
name |
?string |
Display name, when the provider supplies one. |
contextWindow |
?int |
Only where the provider exposes it. |
maxOutputTokens |
?int |
Same. |
modalities |
list<string> |
Input modalities. Empty when unknown. |
outputModalities |
list<string> |
Output modalities. Empty when unknown. |
createdAt |
?DateTimeImmutable |
When the provider reports it. |
raw |
array<string, mixed> |
The untouched provider payload. |
The facade is a thin proxy over a ModelRegistry singleton, which is equally resolvable through constructor injection if you would rather not use facades:
use Laravel\\Ai\\Enums\\Lab;
use LmSomeco\\AiModels\\Facades\\AiModels;
AiModels::all(); // Collection<AiModel> across every configured provider
AiModels::provider(Lab::Anthropic); // one provider, accepts a Lab or its string value
AiModels::configuredProviders(); // names of providers that have the credentials they need
AiModels::refresh(Lab::Groq); // drop one provider's cached list
Because these are ordinary Laravel collections, filtering needs no special API:
$model = AiModels::provider(Lab::OpenAI)->firstWhere('id', 'gpt-4o');
raw is deliberately kept on the DTO but dropped from toArray(), so --json output and API responses stay compact while provider-specific fields (pricing, per-provider limits, anything the normalizer does not model) remain reachable in code.
Modalities: known, and honestly unknown
This is the part worth reading closely, because it is where a normalizing layer can quietly lie to you.
An empty modality array means unknown, not "no". The distinction matters because the live sources of capability data are not equally good:
- OpenRouter returns
architecture.input_modalitiesandarchitecture.output_modalitiesdirectly in its listing response. This is real data from the provider. - Anthropic 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.
- OpenAI returns no capability fields at all.
OpenAiResolverenriches 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.
The other OpenAI-compatible providers (Groq, Mistral, DeepSeek, xAI) go through the base resolver, which maps id and created and leaves capabilities empty, because those endpoints do not report them.
Two helpers, accepts() and generates(), make capability checks readable, and the difference between a strict and a permissive filter explicit:
use LmSomeco\\AiModels\\Data\\AiModel;
// Strict: text-out only, and excludes anything whose capabilities are unknown.
$textOnly = AiModels::all()->filter(
fn (AiModel $model): bool => $model->outputModalities === ['text'],
);
// Permissive: keeps unknowns, excludes only models known to generate image or audio.
$probablyTextOnly = AiModels::all()->reject(
fn (AiModel $model): bool => $model->generates('image') || $model->generates('audio'),
);
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.
Caching, because these are HTTP calls
Model lists are fetched over HTTP, so they are cached per provider rather than re-requested on every call. Keys are {prefix}:{provider-name} (default prefix ai-models, so ai-models:openai), with AI_MODELS_CACHE_STORE selecting the store and AI_MODELS_CACHE_TTL the lifetime: 3600 seconds by default, or null to cache forever and bust manually.
Busting is available at every level: php artisan ai:models --refresh from the CLI, AiModels::refresh() for every declared provider, AiModels::refresh('openai') for one, or a $fresh = true argument on all(), provider() and driver().
Two details in the cache layer are worth knowing about, because both are the kind of thing you would otherwise debug in production:
Unusable entries are discarded, not fatal. A cache entry holding objects whose class this deployment can no longer load unserializes as __PHP_Incomplete_Class. Rather than throwing a TypeError, 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.
A refetch that fails does not leave stale data behind. The unusable entry is forgotten before the refetch runs, so a broken payload cannot outlive a refetch that throws.
That failure is worth planning for. Resolvers call ->throw() on the HTTP response, so a provider outage or a revoked key surfaces as an Illuminate\\Http\\Client\\RequestException out of models(), not as an empty collection. If you are rendering a model picker, catch it; the default 15-second timeout (AI_MODELS_TIMEOUT) bounds how long you wait.
Coverage is partial, and the gaps are documented
Seven providers are live today:
Provider (Lab) |
Resolver | Notes |
|---|---|---|
OpenAI |
OpenAiResolver |
Catalog-enriched modalities |
Groq, Mistral, DeepSeek, xAI |
OpenAiCompatibleResolver |
Differ only by base URL |
OpenRouter |
OpenRouterResolver |
Rich metadata, no key required |
Anthropic |
AnthropicResolver |
x-api-key auth, paginated |
Eight more are mapped to a Lab in laravel/ai 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 GET /api/tags with a different JSON shape entirely, Azure is deployment-based with an api-version 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.
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.
Per-tenant keys: database connectors
The default assumption is that providers live in config/ai.php. 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 AI_MODELS_CONNECTORS=true and a publishable migration.
The useful piece is what ConnectorManager::configure() returns:
$providerKey = $connectorManager->configure($connector); // 'db-{id}'
$models = AiModels::provider($providerKey);
Laravel\\Ai\\Facades\\Ai::provider($providerKey)->ask(/* ... */);
It registers a runtime config('ai.providers.db-{id}') entry and hands back the key, which works anywhere a provider name is expected, including in laravel/ai itself. Because resolvers are selected by the entry's driver 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 .env file.
The shipped ai_connectors table stores api_key with Laravel's encrypted cast, so it is encrypted at rest via APP_KEY. Nothing in the package depends on the concrete Eloquent model, though. Everything is typed against a Connector contract of four getters and two static finders. Your own model can satisfy it by extending the shipped one, by using the IsConnector trait when your columns match the standard layout, or by implementing the six methods against a completely different schema. A misconfigured connectors.model throws an InvalidArgumentException naming the offending class when ConnectorManager is first resolved, rather than failing obscurely later.
Adding a provider
The extension point is a three-method interface:
interface ProviderResolver
{
public function provider(): Lab;
public function configured(): bool;
/** @return Collection<int, AiModel> */
public function models(): Collection;
}
If your provider speaks OpenAI's dialect (GET /models returning {\"data\": [...]} with Bearer auth), extend OpenAiCompatibleResolver and override only what differs, usually mapModel() to pull in extra fields. OpenRouterResolver is the worked example: it overrides configured() because the endpoint needs no key, and mapModel() to read context_length, top_provider.max_completion_tokens and the architecture modalities.
Register it by mapping a Lab value in config/ai-models.php:
'resolvers' => [
Lab::Gemini->value => [
'driver' => \\App\\Ai\\Resolvers\\GeminiResolver::class,
],
],
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 config/ai.php entry is merged on top, and timeout is filled in last. Your application's config always wins.
Conclusion
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. php artisan ai:models is the whole onboarding story.
The constraints are worth restating plainly. It requires PHP 8.3+, laravel/ai ^0.7 through ^0.10, 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.
For an application already running on laravel/ai that needs to stop hardcoding model IDs, that is a reasonable trade.