~/writing/mistral-vibe-devex-audit

September 10, 2026 · 31 min read · DevEx · AI tooling · API design · Debugging

What does Mistral's Free tier actually give a developer?

A survey of Mistral's Free tier turned into API instrumentation, a model-capability map, and eventually a DevEx audit of Mistral Vibe.

I did not start this investigation because I wanted to debug Mistral Vibe.

I started with a much broader question:

What does Mistral actually offer a developer today?

Mistral’s platform had grown well beyond a single family of chat models: coding models, smaller local-friendly models, audio, OCR, moderation, embeddings, Labs models, and a CLI coding agent. Then I noticed there was a Free tier.

That immediately led to the more useful question:

What can I actually use on the Free tier?

Not what exists in the catalog. Not what appears in documentation. Not what has a pricing row. What can an authenticated Free account actually call, through which interface, and with what limits?

Vibe looked like the obvious developer-facing place to start. It is a local CLI coding agent, it supports Mistral-hosted models, and the Free account exposed Vibe-specific usage. So instead of beginning with synthetic API tests, I tried the product the way a developer reasonably would.

That is where the rabbit hole started.

From platform survey to DevEx audit

The investigation eventually looked like this:

The investigation path
  1. 01 Mistral platform
  2. 02 Free tier
  3. 03 Vibe entry point
  4. 04 Browser login
  5. 05 Credential failure
  6. 06 HTTP 429
  7. 07 0 RPM
  8. 08 46 model IDs
  9. 09 Capability probes
  10. 10 Labs state
  11. 11 Empirical map
  12. 12 DevEx audit
investigation-path.txt text Show source
What does Mistral offer today?

there is a Free tier

what is actually included?

Vibe is a developer-facing entry point

install Vibe

browser login

login succeeds

try default Medium 3.5

API / authentication error

check Mistral account online

API / Vibe key exists

check local credential state

~/.vibe/.env DOES NOT EXIST

create a new Vibe API key manually

manually create ~/.vibe/.env

authentication works

request STILL doesn't work ಠ_ಠ

debug console reveals HTTP 429

direct API request + response headers

Medium 3.5 = 0 RPM

"okay, what CAN I use?"

/model does not answer that

GET /v1/models

46 IDs ಠ_ಠ

build instrumentation

map aliases, capabilities and limits

discover first generic probe is incomplete

build capability-specific probes

discover Labs activation state

discover two different Privacy pages

Leanstral = 38 RPM / 5M TPM

test embeddings / moderation / OCR

test transcription / TTS / realtime

build an empirical Free-tier capability map

compare what the platform knows
with what Vibe communicates

DevEx diagnosis

The interesting part is not that I was able to debug this.

A developer using a CLI agent can reasonably be expected to understand environment variables, HTTP errors and API keys. The problem is that none of this investigation was the task I wanted to perform.

I wanted to find out what Mistral’s Free tier could do.

Instead, I first had to reconstruct the state of the platform.

The first failure was real: credential provisioning

Vibe v2.25.1 started on my Mistral Free EU account with Medium 3.5 selected.

I used Vibe’s browser login. The browser flow completed successfully. Then I sent a trivial prompt and received an authentication/API error.

At that point authentication was the obvious hypothesis.

Credential provisioning → rate-limit diagnosis
  1. 01 Install Vibe
  2. 02 Browser login succeeds
  3. 03 Medium 3.5 request fails
  4. 04 API key exists online
  5. 05 ~/.vibe/.env missing
  6. 06 Manual key provisioning
  7. 07 Authentication works
  8. 08 Request still fails
  9. 09 Debug console: 429
  10. 10 Direct API probe
  11. 11 Medium 3.5: 0 RPM

I ran setup again:

vibe --setup

The browser/setup flow reported success.

I checked the Mistral account online. An API/Vibe credential existed there. From the service side, the account looked provisioned.

Then I checked what Vibe actually had locally:

test -f ~/.vibe/.env; and echo exists; or echo missing

Result:

missing

The browser login had succeeded, but ~/.vibe/.env had not been created.

That distinction matters. “Authentication succeeded in the browser” and “the local CLI has usable credentials” are two different states, but the setup flow had presented them as one successful operation.

I created a new Vibe API key manually and provisioned the local state myself:

~/.vibe/.env

MISTRAL_API_KEY=...

For shell-level API debugging I could then load it without printing the secret:

set -lx MISTRAL_API_KEY 
    (string replace 'MISTRAL_API_KEY=' '' < ~/.vibe/.env)

echo "MISTRAL_API_KEY loaded, length:" 
    (string length -- $MISTRAL_API_KEY)

Authentication now worked.

That should have ended the debugging.

It did not.

The second failure was independent

The same default model still could not complete a request.

Instead of Invalid API key, Vibe now kept retrying. The useful clue appeared in debug output:

Retrying request category=rate_limited detail=HTTP 429

So the credential problem had been real, but fixing it had merely exposed a second problem.

I removed Vibe from the equation and called the API directly.

A minimal diagnostic request looked like this:

set MODEL mistral-medium-latest

curl -sS -D /tmp/mistral-headers 
    https://api.mistral.ai/v1/chat/completions 
    -H "Authorization: Bearer $MISTRAL_API_KEY" 
    -H "Content-Type: application/json" 
    -d (jq -nc --arg model "$MODEL" '{
        model: $model,
        messages: [{role:"user", content:"hi"}],
        max_tokens: 1
    }') 
    -o /tmp/mistral-body

cat /tmp/mistral-headers
jq . /tmp/mistral-body

The response body said:

Raw API response · measured artifact
{
	"object": "error",
	"message": "Rate limit exceeded",
	"type": "rate_limited",
	"code": "1300",
	"raw_status_code": 429
}

The interesting evidence was in the headers:

x-ratelimit-limit-req-minute: 0
x-ratelimit-remaining-req-minute: 0

This was not a temporarily exhausted minute.

For this account, the effective request allowance for Medium 3.5 was exactly:

0 RPM

That is a materially different state from “you are being rate limited because you sent too many requests”.

Establishing a control

Before concluding that the Free account or API was generally broken, I needed a control.

I sent the same class of request with the same API key to:

ministral-3b-latest

That returned HTTP 200.

Its response headers included:

x-ratelimit-limit-tokens-minute: 1300000
x-ratelimit-remaining-tokens-minute: 1299995
x-ratelimit-tokens-query-cost: 5
x-ratelimit-limit-req-minute: 750
x-ratelimit-remaining-req-minute: 749

That isolated the state cleanly:

credential valid           yes
API reachable              yes
chat completions working   yes
Free account usable        yes
Medium 3.5 usable          no: effective 0 RPM

This was the point where the original platform-survey question became much more interesting.

If Vibe’s default hosted model had zero allowance, which models could the Free account actually use?

/model could not answer the question

The obvious next action was to switch models.

Vibe’s /model picker did not give me an operational view of account availability. It showed a small set of choices, but it did not answer:

Which hosted models are available to this account?
Which are chat/coding models?
Which are aliases?
Which have non-zero limits?
Which require another account setting?

So I went underneath Vibe again.

curl -sS 
    https://api.mistral.ai/v1/models 
    -H "Authorization: Bearer $MISTRAL_API_KEY" 
    | jq .

The authenticated endpoint returned:

46 model IDs returned by /v1/models catalog discovery

Catalog discovery

46 model IDs Raw provider inventory

Operational result

Empirical capability map Usability requires entitlement, limits, and the correct endpoint

That was more information, but not yet an answer.

The model catalog is structured data, not a picker

The /v1/models response was useful because it contained more than names.

It exposed machine-readable capability state such as:

{
	"completion_chat": true,
	"function_calling": true,
	"reasoning": true,
	"completion_fim": false,
	"vision": true,
	"ocr": false,
	"classification": false,
	"moderation": false,
	"audio": false,
	"audio_transcription": false,
	"audio_transcription_realtime": false,
	"audio_speech": false
}

It also exposed relationships between IDs.

Several of the 46 entries were aliases rather than independent model families:

Mistral Medium 3.5
├─ mistral-medium-latest
├─ mistral-medium
├─ mistral-medium-3
├─ mistral-medium-3-5
├─ mistral-medium-3.5
├─ mistral-medium-2604
├─ mistral-vibe-cli-latest
├─ mistral-vibe-cli-with-tools
└─ magistral-medium-latest

Mistral Small 4
├─ mistral-small-latest
├─ mistral-vibe-cli-fast
└─ magistral-small-latest

Codestral 2508
├─ codestral-latest
├─ mistral-code-latest
└─ mistral-code-fim-latest

That immediately suggests an important distinction for developer tooling:

A provider catalog is not the same abstraction as a model-selection UI.

The catalog contains raw provider state. A useful picker needs to normalize that state into decisions.

But before designing anything, I wanted empirical data.

Instrument the account instead of guessing

Trying 46 IDs manually would be tedious and difficult to reproduce.

So I wrote a small Fish probe that:

  • fetches the authenticated model catalog;
  • discovers IDs dynamically;
  • sends the smallest useful chat request;
  • records HTTP status;
  • captures rate-limit headers;
  • preserves structured API error types;
  • sleeps between requests to avoid turning the probe itself into the rate-limit problem.

The core probe:

probe-free-models.fish fish Show source
#!/usr/bin/env fish

if not set -q MISTRAL_API_KEY
    if test -f ~/.vibe/.env
        set -lx MISTRAL_API_KEY 
            (string replace 'MISTRAL_API_KEY=' '' < ~/.vibe/.env)
    end
end

if not set -q MISTRAL_API_KEY
    echo "MISTRAL_API_KEY is not set."
    exit 1
end

set MODEL_BODY /tmp/mistral-models.json
set BODY /tmp/mistral-probe-body
set HEADERS /tmp/mistral-probe-headers

curl -sS 
    https://api.mistral.ai/v1/models 
    -H "Authorization: Bearer $MISTRAL_API_KEY" 
    > $MODEL_BODY

set MODELS (jq -r '.data[].id' $MODEL_BODY | sort)

echo "Found "(count $MODELS)" models"

for MODEL in $MODELS
    echo
    echo "=== $MODEL ==="

    set HTTP (curl -sS 
        -o $BODY 
        -D $HEADERS 
        -w '%{http_code}' 
        https://api.mistral.ai/v1/chat/completions 
        -H "Authorization: Bearer $MISTRAL_API_KEY" 
        -H "Content-Type: application/json" 
        -d (jq -nc --arg model "$MODEL" '{
            model: $model,
            messages: [{role:"user", content:"hi"}],
            max_tokens: 1
        }'))

    echo "HTTP: $HTTP"

    set RPM (grep -i '^x-ratelimit-limit-req-minute:' $HEADERS 
        | string replace -ri '^.*: *' '')
    set REM_RPM (grep -i '^x-ratelimit-remaining-req-minute:' $HEADERS 
        | string replace -ri '^.*: *' '')
    set TPM (grep -i '^x-ratelimit-limit-tokens-minute:' $HEADERS 
        | string replace -ri '^.*: *' '')
    set REM_TPM (grep -i '^x-ratelimit-remaining-tokens-minute:' $HEADERS 
        | string replace -ri '^.*: *' '')

    test -n "$RPM"; and echo "RPM: $RPM"
    test -n "$REM_RPM"; and echo "Remaining RPM: $REM_RPM"
    test -n "$TPM"; and echo "TPM: $TPM"
    test -n "$REM_TPM"; and echo "Remaining TPM: $REM_TPM"

    jq '{
        error_type: .type,
        error_code: .code,
        message: .message,
        model: .model,
        usage: .usage
    }' $BODY 2>/dev/null; or cat $BODY

    sleep 1.1
end

rm -f $MODEL_BODY $BODY $HEADERS
The complete 74-line probe remains available, but no longer dominates the article's reading flow.

The important design choice here is preserving the distinction between:

200
400 invalid_model
403 tier_not_allowed
403 labs_not_enabled
429 rate_limited

Flattening all of those into available = false would throw away exactly the state I was trying to understand.

First empirical map

The first chat-oriented pass produced this useful subset:

Model familyObserved stateRPMTPM
Ministral 3BAvailable7501,300,000
Ministral 8BAvailable188625,000
Ministral 14BAvailable30937,500
Codestral / Mistral CodeAvailable125625,000
Voxtral SmallAvailable6050,000
Medium 3.5 aliasesEffective 0 RPM0
Small 4 aliasesEffective 0 RPM0
Vibe CLI aliasesEffective 0 RPM0
750 RPM Ministral 3B 1.3M TPM
125 RPM Codestral 625k TPM
0 RPM Medium 3.5 aliases effective allowance

Two other documented models were useful controls even though they were not part of the account’s returned 46-ID catalog:

zai-glm-5-2
→ 403 tier_not_allowed

mistral-large-latest
→ 403 tier_not_allowed

That distinction is useful:

known model + direct 403 tier_not_allowed

model returned as available by authenticated /v1/models

model with non-zero operational allowance

The Vibe-specific IDs were particularly interesting.

The catalog linked:

mistral-vibe-cli-fast
→ Mistral Small 4

mistral-vibe-cli-latest
→ Mistral Medium 3.5

mistral-vibe-cli-with-tools
→ Mistral Medium 3.5

So these were not three mysterious hidden Vibe models. They were aliases into the same families that were already returning zero RPM.

Then my probe was wrong

This is one of the more important parts of the investigation.

The generic probe sent every ID through:

POST /v1/chat/completions

A number of models returned:

400 invalid_model

It would have been easy to turn that into a clean-looking table:

400 = unavailable on Free

It would also have been wrong.

The /v1/models capability metadata already contained the clue. Some of these IDs were embeddings, OCR, moderation, transcription, text-to-speech or realtime-transcription models.

The request was invalid because I was asking the wrong API question.

That changes the interpretation:

400 invalid_model on /chat/completions

means:

this model cannot be used through this endpoint

not:

this account cannot use this model

So instead of defending the first probe, I changed the probe strategy.

Initial interpretation

400 invalid_model Free cannot use this model

Correct interpretation

Wrong capability endpoint The model may be usable through another API

From model probing to capability probing

The better abstraction is:

Capability to endpoint map
GET /v1/models
  • completion_chat /v1/chat/completions chat and coding
  • embeddings /v1/embeddings vector representations
  • moderation /v1/moderations classification
  • ocr /v1/ocr documents and images
  • audio_transcription /v1/audio/transcriptions speech to text
  • audio_speech /v1/audio/speech text to speech

That is a much more useful model for both testing and product design.

Embeddings

The embedding probe used /v1/embeddings:

for MODEL in 
    codestral-embed 
    codestral-embed-2505 
    mistral-embed 
    mistral-embed-2312

    echo
    echo "=== $MODEL ==="

    set HTTP (curl -sS 
        -o /tmp/mistral-body 
        -D /tmp/mistral-headers 
        -w '%{http_code}' 
        https://api.mistral.ai/v1/embeddings 
        -H "Authorization: Bearer $MISTRAL_API_KEY" 
        -H "Content-Type: application/json" 
        -d (jq -nc --arg model "$MODEL" '{
            model: $model,
            input: ["hello"]
        }'))

    echo "HTTP: $HTTP"
    grep -i '^x-ratelimit-' /tmp/mistral-headers; or true
    jq '{model, usage, error: .message}' /tmp/mistral-body
end

All four worked:

codestral-embed       → 200 · 60 RPM
codestral-embed-2505  → 200 · 60 RPM
mistral-embed         → 200 · 60 RPM
mistral-embed-2312    → 200 · 60 RPM

Moderation

curl -sS 
    -o /tmp/mistral-body 
    -D /tmp/mistral-headers 
    https://api.mistral.ai/v1/moderations 
    -H "Authorization: Bearer $MISTRAL_API_KEY" 
    -H "Content-Type: application/json" 
    -d '{
        "model": "mistral-moderation-2603",
        "input": ["hello"]
    }'

grep -i '^x-ratelimit-' /tmp/mistral-headers
jq . /tmp/mistral-body

Result:

mistral-moderation-2603
→ 200
→ 100 RPM

OCR

The OCR family was tested against /v1/ocr.

A representative request:

set MODEL mistral-ocr-latest

curl -sS 
    -o /tmp/mistral-body 
    -D /tmp/mistral-headers 
    https://api.mistral.ai/v1/ocr 
    -H "Authorization: Bearer $MISTRAL_API_KEY" 
    -H "Content-Type: application/json" 
    -d (jq -nc --arg model "$MODEL" '{
        model: $model,
        document: {
            type: "document_url",
            document_url: "https://example.com/test.pdf"
        }
    }')

jq . /tmp/mistral-body

For the actual investigation I used a valid test document. The important result was that all tested OCR IDs returned HTTP 200:

mistral-ocr-2512
mistral-ocr-3
mistral-ocr-3-0
mistral-ocr-4
mistral-ocr-4-0
mistral-ocr-4-1
mistral-ocr-latest

I did not observe the same RPM/TPM headers for OCR, so I did not invent a quota value.

That sounds obvious, but it is an important testing principle:

Absence of a measured value is not permission to infer one.

Labs was another state dimension

Two Leanstral IDs initially returned:

403 labs_not_enabled

That is a better error than a generic failure because it encodes an actionable state.

Labs activation path
  1. 01 403 labs_not_enabled
  2. 02 Error points to Privacy
  3. 03 Vibe Privacy: no toggle
  4. 04 API Privacy: toggle exists
  5. 05 Enable Labs
  6. 06 Leanstral works

The remaining problem was finding the setting.

There were two relevant Privacy pages:

Vibe → Privacy
API  → Privacy

The Labs activation toggle was under:

API → Privacy

After enabling Labs I reran a minimal probe:

for MODEL in labs-leanstral-1-5 labs-leanstral-1-5-1
    echo
    echo "=== $MODEL ==="

    curl -sS -D /tmp/mistral-labs-headers 
        https://api.mistral.ai/v1/chat/completions 
        -H "Authorization: Bearer $MISTRAL_API_KEY" 
        -H "Content-Type: application/json" 
        -d (jq -nc --arg model "$MODEL" '{
            model: $model,
            messages: [{role:"user", content:"hi"}],
            max_tokens: 1
        }') 
        | jq

    echo "--- limits ---"
    grep -i '^x-ratelimit-' /tmp/mistral-labs-headers
end

Both worked immediately:

labs-leanstral-1-5
38 RPM
5,000,000 TPM

labs-leanstral-1-5-1
38 RPM
5,000,000 TPM

At the time of this test, Leanstral had the highest TPM allowance of any chat model I measured on this Free account.

Labs before

403 labs_not_enabled Capability gated behind an account setting

Labs after

Leanstral · 5M TPM 38 RPM after enabling Labs in API → Privacy

Which produced a fairly absurd contrast:

Vibe default:
Mistral Medium 3.5
→ 0 RPM

coding-focused Labs model:
Leanstral 1.5
→ 38 RPM
→ 5,000,000 TPM

The capability existed.

The developer-facing discovery path did not communicate it.

Audio made the Free tier look even better

The remaining 400 invalid_model results included Voxtral models.

Again, the catalog’s capability metadata told me which APIs to use.

For offline transcription and TTS I generated a one-second local WAV so the probe did not depend on an external audio source:

ffmpeg -loglevel error -y 
    -f lavfi -i "sine=frequency=440:duration=1" 
    -ar 16000 -ac 1 
    /tmp/mistral-probe.wav

Offline transcription

for MODEL in voxtral-mini-2602 voxtral-mini-latest
    echo
    echo "=== $MODEL ==="

    set HTTP (curl -sS 
        -o /tmp/mistral-audio-body 
        -D /tmp/mistral-audio-headers 
        -w '%{http_code}' 
        https://api.mistral.ai/v1/audio/transcriptions 
        -H "Authorization: Bearer $MISTRAL_API_KEY" 
        -F "model=$MODEL" 
        -F "file=@/tmp/mistral-probe.wav")

    echo "HTTP: $HTTP"
    grep -i '^x-ratelimit-' /tmp/mistral-audio-headers; or true
    jq . /tmp/mistral-audio-body
end

Both aliases returned 200.

The headers exposed a different quota dimension:

x-ratelimit-limit-audio-seconds-minute: 3600
x-ratelimit-limit-req-minute: 60

So the observed Free allowance was:

60 RPM
3,600 audio seconds/minute

The one-second sine wave was genuinely processed:

prompt_audio_seconds: 1
audio_tokens: 375

The empty transcription was expected; the input contained no speech.

TTS and voice discovery

Instead of hardcoding a voice ID, I queried the voice API first:

curl -sS 
    "https://api.mistral.ai/v1/audio/voices?type=preset&limit=5" 
    -H "Authorization: Bearer $MISTRAL_API_KEY" 
    | jq .

The account exposed:

30 preset voices

Then I used one returned voice ID to probe TTS:

probe-tts.fish fish Show source
set VOICE_ID (curl -sS 
    "https://api.mistral.ai/v1/audio/voices?type=preset&limit=1" 
    -H "Authorization: Bearer $MISTRAL_API_KEY" 
    | jq -r '.items[0].id')

for MODEL in voxtral-mini-tts-2603 voxtral-mini-tts-latest
    echo
    echo "=== $MODEL ==="

    set HTTP (curl -sS 
        -o /tmp/mistral-tts-body 
        -D /tmp/mistral-tts-headers 
        -w '%{http_code}' 
        https://api.mistral.ai/v1/audio/speech 
        -H "Authorization: Bearer $MISTRAL_API_KEY" 
        -H "Content-Type: application/json" 
        -d (jq -nc 
            --arg model "$MODEL" 
            --arg voice "$VOICE_ID" 
            '{
                model: $model,
                input: "Hello.",
                voice_id: $voice,
                response_format: "wav",
                stream: false
            }'))

    echo "HTTP: $HTTP"
    grep -i '^x-ratelimit-' /tmp/mistral-tts-headers; or true

    jq 'if .audio_data then {
        success: true,
        audio_bytes_approx:
            ((.audio_data | length) * 3 / 4 | floor)
    } else . end' /tmp/mistral-tts-body
end

Both TTS IDs returned 200.

Observed limit:

12,000 input characters/minute

Again, the original 400 invalid_model had not been a subscription restriction. It had been a bad endpoint choice in my first probe.

Realtime needed a different protocol entirely

The final specialized family was realtime transcription.

This could not be tested honestly by sending another REST request. I used Mistral’s realtime Python client instead.

Temporary environment:

python -m venv /tmp/mistral-realtime-probe
source /tmp/mistral-realtime-probe/bin/activate.fish
pip install 'mistralai[realtime]'

Generate raw PCM:

ffmpeg -loglevel error -y 
    -f lavfi -i "sine=frequency=440:duration=1" 
    -f s16le -acodec pcm_s16le 
    -ar 16000 -ac 1 
    /tmp/mistral-realtime.pcm

The probe:

probe-realtime.py python Show source
import asyncio
import os
from pathlib import Path

from mistralai.client import Mistral
from mistralai.client.models import AudioFormat

MODELS = [
    "voxtral-mini-transcribe-realtime-2602",
    "voxtral-mini-realtime-2602",
    "voxtral-mini-realtime-latest",
]

PCM = Path("/tmp/mistral-realtime.pcm")
CHUNK_BYTES = 7680  # ~240 ms @ 16 kHz mono s16le


async def audio_stream():
    data = PCM.read_bytes()

    for pos in range(0, len(data), CHUNK_BYTES):
        yield data[pos : pos + CHUNK_BYTES]
        await asyncio.sleep(0.24)


async def probe(model):
    print()
    print(f"=== {model} ===")

    client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])

    audio_format = AudioFormat(
        encoding="pcm_s16le",
        sample_rate=16000,
    )

    try:
        got_event = False

        async for event in client.audio.realtime.transcribe_stream(
            audio_stream=audio_stream(),
            model=model,
            audio_format=audio_format,
        ):
            got_event = True
            event_type = getattr(event, "type", type(event).__name__)
            print(f"EVENT: {event_type}")

            for field in (
                "text",
                "error",
                "message",
                "code",
                "usage",
            ):
                value = getattr(event, field, None)
                if value not in (None, "", [], {}):
                    print(f"  {field}: {value}")

        if got_event:
            print("RESULT: stream completed")
        else:
            print("RESULT: stream completed without events")

    except Exception as exc:
        print("RESULT: ERROR")
        print(f"TYPE: {type(exc).__name__}")
        print(f"ERROR: {exc}")


async def main():
    for model in MODELS:
        await probe(model)


asyncio.run(main())
The complete streaming probe remains available for reproducibility without interrupting the surrounding result narrative.

All three IDs completed a real streaming session:

session.created
session.updated
transcription.done
RESULT: stream completed

Usage was reported as well:

prompt_tokens=23
completion_tokens=23
total_tokens=46
prompt_audio_seconds=2

At this point every specialized model family that had produced 400 invalid_model in the generic chat probe had worked when tested through the correct API or protocol.

That is a much more useful result than the original 400s.

The resulting empirical capability map

For this specific Mistral Free EU organization on 2026-09-10, the useful observed state was:

Chat and coding

Model familyObserved stateRPMTPM
Leanstral 1.5Available after Labs opt-in385,000,000
Ministral 3BAvailable7501,300,000
Ministral 14BAvailable30937,500
Ministral 8BAvailable188625,000
Codestral / Mistral CodeAvailable125625,000
Voxtral SmallAvailable6050,000
Medium 3.5 aliasesEffective 0 RPM0
Small 4 aliasesEffective 0 RPM0
Vibe CLI aliasesEffective 0 RPM0
GLM 5.2Tier denied
Large 3Tier denied

Specialized APIs

CapabilityObserved stateObserved limit
EmbeddingsAvailable60 RPM
ModerationAvailable100 RPM
OCRAvailableNo equivalent RPM/TPM header observed
Offline transcriptionAvailable60 RPM + 3,600 audio seconds/min
TTSAvailable12,000 input characters/min
Realtime transcriptionAvailableSuccessful streaming sessions
Preset voice discoveryAvailable30 voices observed

These numbers are observations, not promises.

They describe one account, one organization, one region and one point in time.

That distinction matters for any empirical platform investigation.

What the experiment actually says about Mistral Free

The initial Vibe experience made the platform look worse than it was.

The deeper probe showed almost the opposite.

The Free account had access to:

  • several usable hosted chat/coding models;
  • a coding-focused Labs model with a 5M TPM allowance;
  • embeddings;
  • moderation;
  • multiple OCR aliases;
  • offline audio transcription;
  • realtime transcription;
  • TTS;
  • global preset voices.

The problem was not primarily capability scarcity.

It was capability legibility.

That is where this stopped being a Free-tier benchmark and became a DevEx audit.

The missing abstraction

The recurring failure pattern was that raw platform state existed, but the developer had to translate it manually.

Availability is a chain of distinct states
  1. Discovered
  2. Entitled
  3. Non-zero limit
  4. Correct capability
  5. Usable in Vibe

During one model-selection task I had to determine:

Did browser authentication actually provision the CLI?

Is the API key valid?

Is this a global API failure or model-specific?

Does 429 mean exhausted quota or a configured limit of zero?

Is this model returned for my account?

Is this ID canonical or an alias?

Is this a chat model?

Which capability does it expose?

Which endpoint implements that capability?

Does it require Labs?

Does 403 mean Labs disabled or subscription tier denied?

Which alternative model can I use right now?

Most of the ingredients already existed somewhere:

/v1/models
→ IDs
→ aliases
→ capabilities

API errors
→ labs_not_enabled
→ tier_not_allowed
→ rate_limited

response headers
→ RPM
→ TPM
→ audio-seconds/min
→ input-characters/min

Vibe
→ provider
→ selected model
→ authentication/setup state

What was missing was a normalized representation between provider state and developer-facing decisions.

Conceptually:

raw provider state

discovery

normalization
        ├── canonical model
        ├── aliases
        ├── capabilities
        └── provider

availability enrichment
        ├── tier state
        ├── Labs state
        ├── observed limits
        └── last relevant error

cached ModelAvailability

consumers
        ├── /model
        ├── setup
        ├── retry/error handling
        └── fallback suggestions

That is a broader change than making /model prettier.

It is a UX and DX fix.

What /model could become

Once the client has normalized provider state, the picker becomes relatively simple.

For example:

Select model

Hosted · Available
  ● Codestral 2508       125 RPM · 625K TPM
  ● Ministral 3B         750 RPM · 1.3M TPM
  ● Ministral 8B         188 RPM · 625K TPM
  ● Ministral 14B         30 RPM · 937K TPM
  ● Leanstral 1.5         38 RPM · 5M TPM      Labs

Hosted · Unavailable
  ○ Mistral Medium 3.5     0 RPM                Rate limit
  ○ Mistral Small 4        0 RPM                Rate limit

Local
  ● Devstral

The exact UI is less important than the state model behind it.

A Labs error can become:

Leanstral 1.5 is disabled for this organization.

Enable Labs under:
API → Privacy

A zero-RPM request can become:

Mistral Medium 3.5 is currently unavailable for this account.

Observed request limit:
0 requests/minute

Available alternatives:
  Codestral 2508
  Ministral 3B
  Ministral 8B
  Ministral 14B

Use /model to switch.

That is much more useful than repeatedly retrying a request whose configured request allowance is zero.

Do not turn discovery into more API traffic

There is an important implementation constraint.

A better picker should not probe every model every time /model opens.

The generic 46-ID probe was appropriate for an investigation. It would be a terrible normal interaction pattern.

Instead:

setup
→ fetch catalog
→ normalize metadata
→ cache

successful API calls
→ update observed limits

relevant API errors
→ update availability state

explicit refresh / cache expiry
→ refresh provider state

/model
→ render cached normalized state

This also separates concerns cleanly.

/model should consume a model-availability abstraction. It should not become a pile of Mistral-specific API rules.

A possible representation

The exact implementation should follow Vibe’s existing architecture, but conceptually I would want something close to:

A normalized model-availability pipeline
  1. 01 Provider
  2. 02 Discovery
  3. 03 Capability normalization
  4. 04 Entitlement
  5. 05 Rate-limit state
  6. 06 ModelAvailability
  7. 07 Picker · setup · fallback · diagnostics
ModelAvailability(
    model_id="codestral-2508",
    canonical_id="codestral-2508",
    aliases=[
        "codestral-latest",
        "mistral-code-latest",
        "mistral-code-fim-latest",
    ],
    capabilities={
        "completion_chat": True,
        "completion_fim": True,
        "function_calling": True,
    },
    status="available",
    limits=RateLimits(
        rpm=125,
        tpm=625_000,
    ),
)

versus:

ModelAvailability(
    model_id="mistral-medium-latest",
    canonical_id="mistral-medium-latest",
    aliases=[
        "mistral-medium",
        "mistral-medium-3.5",
        "mistral-vibe-cli-latest",
        "mistral-vibe-cli-with-tools",
    ],
    status="zero_rate_limit",
    limits=RateLimits(
        rpm=0,
    ),
)

And potentially:

ModelAvailability(
    model_id="labs-leanstral-1-5",
    status="labs_disabled",
    action=EnableLabs(
        location="API → Privacy",
    ),
)

The developer-facing components then do not need to understand every provider failure independently.

They consume normalized state.

Why correcting my own probe matters

The most valuable technical moment in this investigation was not discovering 0 RPM.

It was discovering that my first test was asking the wrong question.

A generic chat probe produced a set of clean 400 invalid_model responses. Those results looked easy to classify. The model metadata contradicted that interpretation.

So I changed the test.

Every specialized family I subsequently tested correctly worked on the Free account.

That is a useful reminder for developer tooling in general:

An error is evidence about the request that produced it, not automatically evidence for the broader conclusion you want to draw.

The same principle applied repeatedly here:

browser login succeeded
≠ local credential provisioned

429 rate_limited
≠ quota temporarily exhausted

model returned by /v1/models
≠ non-zero operational allowance

400 invalid_model on chat endpoint
≠ unavailable model

403
≠ one universal access failure

Preserving those distinctions is what turned debugging into a useful platform model.

External validation

I published the initial findings to r/MistralAI while the investigation was still fresh, then updated the thread after testing Labs and after correcting the specialized-model results.

Reddit’s own insights showed the post briefly reaching roughly:

#3 post of all time in r/MistralAI
~1.4k views
10 shares

within the first ~40 minutes.

More useful than the ranking itself was a comment from another developer who independently described spending too long debugging Vibe authentication and agreed that model availability should be communicated directly by the picker.

That is qualitative validation.

The public write-up also produced a small but useful signal that the findings were actionable rather than merely interesting. One reader summarized it simply:

“Let’s hope Mistral incorporates these findings.”

Since Vibe is open source, that leaves an obvious next step: turn the audit into a contribution.

The stronger evidence remains the reproducible technical state:

HTTP responses
rate-limit headers
/v1/models metadata
capability-specific API calls
realtime protocol events

What started as a survey became the case study

The original question was:

What does Mistral’s Free tier actually give a developer?

The empirical answer was: considerably more than the first-run Vibe experience suggested.

But getting that answer required:

  • repairing a failed local credential-provisioning state;
  • separating authentication from model availability;
  • inspecting raw rate-limit headers;
  • discovering the authenticated model catalog;
  • normalizing aliases;
  • instrumenting 46 returned IDs;
  • recognizing a flaw in my own generic probe;
  • switching from model-based to capability-based testing;
  • finding organization-level Labs activation;
  • testing REST and realtime audio interfaces;
  • and finally reconstructing a usable account capability map.

That is the DevEx problem.

A developer tool does not need to hide the underlying platform.

It should make the platform’s state legible enough that developers can make the next correct decision without reverse-engineering it first.

A developer tool should not require developers to reverse-engineer information that its own platform can already provide programmatically.