Most of the AI conversation this week is about whether the tools will replace us, and I keep thinking that the more practical question is whether you can walk away from one. Last month I needed a working vertical slice of a support-triage feature — an HTTP endpoint, one Postgres row, one queued job — and I had an afternoon, not a sprint. So I put the slice on a free tier and it worked, which turned out to be the problem.
The demo answered requests beautifully. Then a colleague tried to run it on her machine and we spent three hours working out which parts of that "working" system were actually ours. The failure was not in the model call, and it was not in the response time either; it happened one layer down, at the handoff between a demo I could show and an artifact someone else could reproduce.
The demo is not the deliverable
I have started judging every free AI tier by one number that nobody puts on the pricing page, and that number is what it costs to leave. Entry price is easy to read, because it is usually zero and always loud. Exit cost is quiet: it hides in request shapes, in state you did not export, and in an environment you cannot rebuild without the vendor's dashboard open in another tab.
So my position is blunt. A free tier that gives you ten million tokens and a box to run them on is worth exactly as much as your ability to replay the same workload somewhere else. If you cannot do that, you did not evaluate a tool; you rented a demo and mistook the feeling for evidence.
Three exits actually matter, and I test them in this order. The first is the request shape, meaning how much of your code knows the vendor's JSON. The second is state, meaning whether the rows, files, and credentials your slice depends on can leave with a git clone. The third is the environment, meaning whether one command on a clean machine gets you a running service rather than a screenshot of one.
Put the request shape behind a seam you can swap
The first exit is the cheapest to fix and the easiest to forget, because a single httpx.post inside a route handler looks innocent until you have forty of them. I keep one protocol, one adapter, and nothing else in the application layer knows a base URL exists.
# app/providers/base.py from dataclasses import dataclass from typing import Protocol @dataclass(frozen=True) class Completion: text: str model: str request_id: str | None = None class Provider(Protocol): name: str def complete(self, system: str, user: str, *, seed: int | None = None) -> Completion: ... The HTTP adapter below is illustrative, not gospel: confirm the endpoint path and field names against the provider's current docs before you depend on them. What matters is that every vendor-specific detail lives in this one file.
# app/providers/http_provider.py import os import httpx from .base import Completion class HTTPProvider: name = "http" def __init__(self, base_url=None, api_key=None, model=None): self.base_url = (base_url or os.environ["LLM_BASE_URL"]).rstrip("/") self.api_key = api_key or os.environ.get("LLM_API_KEY", "") self.model = model or os.environ["LLM_MODEL"] def complete(self, system: str, user: str, *, seed=None) -> Completion: payload = { "model": self.model, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], } if seed is not None: payload["seed"] = seed r = httpx.post( f"{self.base_url}/chat/completions", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, timeout=60, ) r.raise_for_status() body = r.json() return Completion( text=body["choices"][0]["message"]["content"], model=body.get("model", self.model), request_id=r.headers.get("x-request-id"), ) Now the seam has a contract, and a contract can be tested against any implementation, including a fake that never touches the network. That fake is the second half of the exit, because a provider seam you cannot stub is a provider seam you cannot test on a plane.
# tests/test_provider_contract.py import pytest from app.providers.http_provider import HTTPProvider from app.providers.fake import FakeProvider @pytest.fixture(params=["fake", "http"]) def provider(request): if request.param == "fake": return FakeProvider() return HTTPProvider() def test_returns_non_empty_text(provider): out = provider.complete("You are terse.", "Reply with the word OK.", seed=7) assert out.text.strip(), "empty completion" assert out.model, "provider did not report which model answered" Run it with pytest tests/test_provider_contract.py -k fake in CI on every pull request, and with the real provider before you merge anything that touches prompts. If the second run needs a credential that only lives in someone's browser session, you have already found your exit cost.
Replay the workload instead of admiring it
Comparing two providers by reading two outputs side by side is how people end up arguing about vibes for an hour. I keep a small frozen task file — twenty to fifty rows is plenty — and a harness that records what happened rather than what it looked like.
# scripts/exit_test.py import hashlib, json, pathlib, time from app.providers.http_provider import HTTPProvider from app.providers.fake import FakeProvider TASKS = json.loads(pathlib.Path("eval/tasks.json").read_text()) def run(provider, out_path: str) -> None: rows = [] for t in TASKS: start = time.monotonic() try: c = provider.complete(t["system"], t["user"], seed=t.get("seed")) rows.append({ "task_id": t["id"], "ok": True, "model": c.model, "hash": hashlib.sha256(c.text.encode()).hexdigest()[:16], "chars": len(c.text), "ms": round((time.monotonic() - start) * 1000), }) except Exception as e: rows.append({ "task_id": t["id"], "ok": False, "error": f"{type(e).__name__}: {str(e)[:200]}", "ms": round((time.monotonic() - start) * 1000), }) pathlib.Path(out_path).write_text( "\n".join(json.dumps(r) for r in rows) + "\n" ) if __name__ == "__main__": run(FakeProvider(), "runs/fake.jsonl") run(HTTPProvider(), "runs/http.jsonl") The hashes will not match across providers, and that is the wrong thing to want. What you are reading is a failure histogram: which tasks returned an error, which returned empty text, which took ten times longer than the median, and which simply produced a different answer that a human would still accept. Those four counts tell you whether you are migrating a feature or rewriting one.
The free server is disposable; your state is not
This is where I actually ran the experiment, on the free model access and free server option that the MonkeyCode project offers. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The project advertises a free token allotment (ten million at the time of writing) and a free server option, and both of those are operator-supplied claims, so re-read the current terms before you plan a week around them.
What makes the free server useful for an exit test is boring and specific: it lets you run the whole slice, database included, instead of a notebook cell that proves nothing about persistence. What makes it risky is equally specific, because a box you did not provision is a box whose lifetime and disk you do not control. So I keep migrations in the repository, secrets in the environment, and every artifact the test needs inside docker compose up --build.
# compose.yaml (sketch) services: api: build: . environment: - LLM_BASE_URL=${LLM_BASE_URL} - LLM_API_KEY=${LLM_API_KEY} - LLM_MODEL=${LLM_MODEL} - DATABASE_URL=postgresql://app:app@db:5432/app ports: ["8000:8000"] depends_on: [db] db: image: postgres:16 environment: - POSTGRES_USER=app - POSTGRES_PASSWORD=app - POSTGRES_DB=app volumes: ["pgdata:/var/lib/postgresql/data"] volumes: pgdata: Then the verification is one command and one status code, not a paragraph of description. curl -sf -X POST localhost:8000/triage -d '{"ticket":"refund not received"}' -H 'content-type: application/json' | jq -e '.label' either prints a label or exits non-zero, and either way you learned something true.
| Question | Pass looks like | Fail looks like | What I do next |
|---|---|---|---|
| Request shape | Provider swapped by env var only | Code edits in route handlers | Add the adapter before anything else |
| State | git clone plus migrations rebuilds it | Rows exist only in a dashboard | Export schema, seed data, and fixtures |
| Environment | One command on a clean machine | "Works on the demo box" | Write the compose file, pin the base image |
| Replay | Error histogram is empty or explained | Silent empty completions | Record raw responses before debugging |
The instruction to myself is to run the exit test before the demo, not after. A slice that survives a provider swap in an afternoon is a slice I will still trust in three months, and a slice that only survives where it was born is a story I should stop telling.
What this does not prove, and who should skip it
An exit test proves portability, and it proves nothing about quality, uptime, or cost at volume. A shared free tier will show latency outliers you would never see on a dedicated instance, and a token allotment that exists today is not a budget you can forecast against next quarter. If your workload touches regulated data, if you need a signed data-processing agreement, or if you have a latency SLO with a customer's name on it, this whole approach is the wrong tool and a vendor contract is the right one.
I would also skip it if you are looking for a permanent backend, because a free server is a rehearsal space and treating it as production is how teams discover their disk was ephemeral at the worst possible moment. The point is to make your first real deployment uneventful, not to avoid it.
So here is my ask, and it is the same one I keep failing to answer for myself. Run the exit test on your own slice this week, and tell me which handoff broke first: the request adapter, the state export, or the environment rebuild. Give me the error string or the status code, not the impression — that is the part I can actually use.

