The first LLM integration in an application is usually simple.
You install one SDK, add an API key, choose a model, and call it directly from your backend.
Something like:
client = ProviderClient(api_key=...)
response = client.chat(...)For one feature, that can be perfectly reasonable.
The architecture starts to hurt when the number of AI features grows.
In systems I have worked on, different flows needed things such as:
chat
fact extraction
document enrichment
structured output
agent tool calls
streaming
reasoning
embeddings
model fallbacksAt the same time, the best model for every task was not always the same model or even the same provider.
That is when calling LLM vendors directly from each application started to feel like the wrong abstraction.
I moved toward a middleware/gateway layer between application code and model providers.
Direct provider calls create hidden coupling
At first, provider-specific code looks small.
But the coupling spreads quickly.
Application code starts learning things like:
provider model names
provider authentication
request shape
streaming format
error types
rate-limit headers
timeout behavior
structured-output syntax
provider-specific optionsNow imagine three backend services each implement those details independently.
Changing model provider is no longer:
model A -> model BIt becomes a repository-by-repository migration.
That was the first reason I wanted a stable internal interface.
The application should ask for a capability, not a vendor
I prefer application code that thinks in terms of what it needs.
For example:
fast chat model
high-quality reasoning model
structured extraction model
embedding modelrather than embedding vendor decisions everywhere:
provider-x-model-2026-08-17The middleware can then map an internal alias to the actual provider/model.
Conceptually:
models:
chat-fast: provider_a/model_1
reasoning: provider_b/model_2
extraction: provider_c/model_3Application code calls:
llm.chat(model="chat-fast", ...)The provider choice becomes configuration or platform policy instead of business logic.
One interface makes model switching much cheaper
Model quality changes quickly.
A model that is the best choice today may be too expensive, too slow, deprecated, or simply surpassed a few months later.
Without a gateway, every application may need a code change.
With a gateway, the change can often happen in one place.
This also makes experiments safer.
For example:
10% -> new model
90% -> current modelor:
use cheaper model for extraction
use stronger model only for complex reasoningThe application does not need to know how the routing decision was made.
Authentication belongs at the middleware boundary
Direct integrations also spread API keys across applications.
That increases secret-management complexity.
A middleware layer lets internal services authenticate to one internal AI endpoint, while provider credentials remain concentrated behind that boundary.
The flow becomes:
application
↓
internal auth
↓
LLM middleware
↓
provider credential
↓
external model APIThis does not magically solve security, but it reduces the number of applications that need access to external provider secrets.
It also makes provider key rotation much easier.
Error handling becomes consistent
Every provider fails differently.
You can see:
429 rate limit
provider timeout
connection failure
invalid model
context too large
content policy rejection
malformed structured output
upstream 5xxIf each application integrates directly, each one has to translate those failures into something useful.
A middleware layer can normalize them into a smaller internal error model.
For example:
{
"type": "rate_limit",
"retryable": true,
"provider": "...",
"request_id": "..."
}The application can then make decisions based on a stable contract rather than dozens of vendor-specific exception classes.
Timeouts need to be owned centrally
One lesson I kept seeing in agent and extraction pipelines was that model calls can remain open much longer than expected.
If every application invents its own timeout policy, behavior becomes inconsistent.
One service gives up after 20 seconds.
Another waits for two minutes.
Another retries three times and turns one user request into four expensive model calls.
The middleware is a good place to define sensible defaults around:
connect timeout
read timeout
overall request budget
retry count
retryable errors
stream idle timeoutApplications can still override them for special cases, but there is a safe baseline.
Retries need to understand cost
Normal HTTP retry logic can be dangerous around LLMs.
A request may be expensive.
It may also have reached the provider even if the client did not receive the response.
Blind retries can increase both cost and latency.
I prefer retries only for clearly retryable failures and with a strict upper bound.
For agent flows, I also separate:
LLM retryfrom
tool retrybecause they have very different side-effect risks.
The gateway cannot solve every workflow problem, but it can stop each service from implementing a different retry philosophy.
Usage tracking becomes much easier
Once many features share AI infrastructure, cost attribution starts to matter.
A single provider invoice does not tell you which feature is responsible for the spend.
A middleware request can attach internal metadata such as:
service
feature
tenant
model alias
request typeThen usage can be measured centrally:
input tokens
output tokens
latency
provider
model
estimated cost
success/failureThis is useful for more than billing.
It can reveal architectural problems.
For example:
Why is one extraction feature sending 80k tokens per request?
or:
Why did agent latency double after changing the default model?
Without centralized telemetry, those questions are much harder to answer.
Middleware helps control context size
One mistake in AI applications is letting each feature send whatever context it wants to the provider.
That can create unpredictable cost and latency.
The gateway can enforce or at least observe limits such as:
maximum input size
maximum output tokens
allowed models
allowed features per serviceI still prefer the application to make intelligent context decisions because it understands the task.
But middleware gives the platform a final safety boundary.
Fallbacks sound easier than they are
A common reason to add an LLM gateway is provider fallback.
Conceptually:
provider A fails
↓
call provider BIn practice, models are not perfectly interchangeable.
They may differ in:
context window
structured output behavior
tool-call schema
system prompt handling
reasoning controls
streaming events
multimodal supportSo I do not treat fallback as a universal switch.
Fallbacks work best between models that have been tested against the same internal capability contract.
For example:
chat-fast-primary
chat-fast-secondaryshould both be validated for the same application expectations before they become automatic fallbacks.
Dify, agent frameworks, and middleware solve different problems
Another clarification that helped me was separating orchestration from model access.
An orchestration layer may handle:
prompt pipelines
retrieval
fact extraction
agent workflows
tool executionA model gateway/middleware handles a different responsibility:
provider routing
authentication
model aliases
timeouts
usage
normalized errors
policyThose layers can work together.
I do not want every workflow engine to also become the organization's provider abstraction, and I do not want the provider gateway to contain business-specific orchestration logic.
Keeping those responsibilities separate makes both easier to evolve.
The middleware should stay boring
There is a risk in centralizing infrastructure: the gateway can become too smart.
If every prompt, business rule, RAG decision, memory policy, and agent behavior moves into the middleware, then every AI application becomes coupled to one giant service.
I try to keep the boundary narrow.
The middleware should know things like:
which model
which provider
how to authenticate
how long to wait
how to measure usage
how to normalize failuresThe application should still own:
why the model is being called
what context is relevant
what the output means
what actions are allowed
how domain validation worksThat separation is important.
A practical internal request shape
A simplified internal request can be much more stable than any provider API:
{
"model": "reasoning",
"messages": [],
"max_output_tokens": 2000,
"metadata": {
"service": "question-service",
"feature": "answer-reasoning"
}
}The gateway then translates that into the provider-specific call.
The response can also expose common fields:
{
"content": "...",
"model": "provider/model-version",
"usage": {
"input_tokens": 1234,
"output_tokens": 321
},
"request_id": "..."
}The internal contract does not need to expose every provider feature.
When a feature is genuinely provider-specific, I would rather add it deliberately than leak the entire vendor API through the abstraction.
What I learned
I did not add an LLM middleware layer because calling an AI API was technically difficult.
I added it because operating many AI features consistently became difficult.
The benefits I care about most are:
- one stable interface for applications
- centralized provider credentials
- model aliases instead of vendor names in business code
- normalized errors
- shared timeout and retry policy
- usage and latency visibility
- easier model migration
- controlled fallback
- centralized limits and policy
The middleware does add another service to operate, so I would not introduce it for a single small experiment.
But once several applications and pipelines depend on multiple LLM providers, the alternative is usually hidden duplication spread across every codebase.
At that point, a boring middleware layer becomes very valuable.