Count how many tools on your machine have an AI provider key right now. Your editor. Your coding agent. A couple of scripts. That side project from last month. Each one has its own copy of a key, its own idea of which provider to call, and no shared record of what any of them spent.
Each tool is also locked to whichever API format it was written against. A client built for Anthropic's Messages API can't just talk to an OpenAI-compatible model, and the reverse is true too. So the client you like decides which models you're allowed to use.
ModelPort is my answer to both problems. It's a small server on your own machine that sits between your tools and every provider.
What changes
Clients stop holding provider keys. They get two things: ModelPort's local URL and a ModelPort
token. The real keys stay in one place, in an .env file or encrypted in ModelPort's own
database.
For any tool built on the Anthropic SDK that lets you set a base URL, that's two environment variables:
export ANTHROPIC_BASE_URL=https://127.0.0.1:13243
export ANTHROPIC_AUTH_TOKEN=$MODELPORT_TOKEN
From then on, every request goes through one port. That means one place to swap providers, set up fallbacks, and see exactly what was sent, what came back, how long it took, and what it cost.
The interesting part is translation
ModelPort accepts both OpenAI-style requests (/v1/chat/completions) and Anthropic-style
requests (/v1/messages). It can send either one to either kind of provider. Your client's
format and the model you call are independent.
# An OpenAI-format request, answered by an Anthropic model
curl https://127.0.0.1:13243/v1/chat/completions \
-H "Authorization: Bearer $MODELPORT_TOKEN" \
-H "Content-Type: application/json" \
-H "X-ModelPort-Provider: openrouter" \
-d '{"model": "anthropic/claude-sonnet-4",
"messages": [{"role": "user", "content": "Say hello from ModelPort."}]}'
Plain text messages are easy to translate. The two formats really start to differ in the parts that matter for agents.
System prompts. OpenAI puts them in the message list, as system or developer messages,
and there can be several. Anthropic has a single top-level system field. Going one way means
pulling them out and joining them. Going the other way means turning one field back into a
message.
Tool calls. OpenAI returns a tool call's arguments as a JSON string. Anthropic returns a
tool_use block with an already-parsed input object. Every crossing between the two parses or
serializes, and has to decide what to do when the model produces arguments that aren't valid
JSON.
Tool results. This one is easy to get subtly wrong. OpenAI sends each tool result as its own
message with role: "tool". Anthropic expects all the results for one turn grouped as
tool_result blocks inside a single user message. So the translator has to collect consecutive
tool results and hand them over together. A simplified version:
pending_tool_results = []
def flush_tool_results():
if pending_tool_results:
messages.append(AnthropicMessage(role="user", content=list(pending_tool_results)))
pending_tool_results.clear()
for message in payload.messages:
if message.role == "tool":
pending_tool_results.append(to_tool_result_block(message))
continue
flush_tool_results()
...
Streaming. The two APIs stream with different event shapes, so a streamed response has to be translated event by event, as it arrives, without waiting for the end.
Stop reasons. They even name why the model stopped differently: OpenAI's tool_calls is
Anthropic's tool_use. A client that checks the stop reason to decide whether to run tools will
loop or stall if that mapping is wrong.
Observability without a cloud
Everything ModelPort sees goes into a local SQLite database: provider, model, tokens, latency, estimated cost, and status. The dashboard turns that into an overview, a searchable request log, a model directory with pricing and context sizes, provider health, and costs over time.
Request and response bodies are not stored by default. When you turn I/O logging on to debug something, the request log becomes an inspector, but prompts and tool inputs are sensitive. So that's a switch you flip on purpose, not a default you have to remember to turn off.
Local, on purpose
ModelPort runs on 127.0.0.1 with local TLS via mkcert, so clients can verify the connection
without anyone disabling certificate checks. It's built for one developer's machine, not for
exposing to a network.
That constraint keeps it simple. There are no accounts, no multi-tenancy, and no hosted service that holds your keys. It's a tool you run, configure, and can read the source of.
What's next
The direction is to make model access fully portable: fallback chains that switch providers
automatically, routing by cost or latency, and structured output formats that behave the same
across providers. There's also an interactive modelport-configure command that points Claude
Code at the proxy and lets you pick its default models from any provider.
The source is at github.com/armanghev/modelport. If you're juggling keys across a handful of AI tools, try routing them through one port and see what your request log tells you.