Skip to main content

modelpass

The pass to the models you are entitled to — the subscription you already pay for, a key you already have, or the box on your own desk. One interface over all of them.

Install it with pip install modelpass, import it as modelpass.

Developed under the working titles agentbridge and then subpass. The distribution and the import package are both modelpass as of 0.2.0; import subpass still works through a shim until 0.3.0. See Migrating from subpass.

What this is

modelpass is a Python library for reaching language models from a program without the program having to care how the model is paid for. Six runtimes, one calling convention: the two vendor agent runtimes that run under a subscription you already have — the Claude Agent SDK and the Codex CLI — and four API-key runtimes that bill a key — Anthropic's Messages API, OpenAI's Responses API, the Gemini API, and openai-compatible for anything that speaks the OpenAI shape, a local Ollama or LM Studio or a gateway included.

Each of those is a connection: vendor, identity, runtime, billing mode and a pointer to a credential, in one file that every modelpass tool on the machine reads and that is safe to share, because no key is in it. Keys live in a second file that is not shareable, or in an environment variable the connection names. Configuring a connection is a deliberate act with a receipt in front of it, and nothing ambient can become one on its own: an ANTHROPIC_API_KEY sitting in your environment does nothing at all until a connection names it, and a fresh install has no AI connectivity whatsoever.

Over the six runtimes there is one interface, sync and async: bridge.ask(...) for a single answer, bridge.chat(...) for the event stream, achat / aask / asend for the same things on your own loop — plus your own Python functions as tools, schema-bound output, sessions where the runtime holds one, per-call timeouts and spend guards. And in front of every run there is a receipt: which auth mode is actually about to pay, which sampling fields will really be sent and which are being dropped and why, whether the prompt-cache breakpoints you marked will reach the vendor, and what bounds the run. Afterwards the terminal event carries the same answer — stamped by modelpass, not by the adapter — and one line of it goes to ~/.modelpass/runs.jsonl, so the question survives the run that answered it.

The subscription half, which is where this started

If you pay for Claude Max or a ChatGPT plan, you are already paying for a lot of model capacity. But the moment you want to build something — a script, a small tool, an app with a chat feature — the normal answer is "get an API key", and now you have a second, metered bill for work your subscription would happily have done.

That is the half modelpass started as, and it is still the half nothing else does. It drives the agent runtimes the vendors already ship — the Claude Agent SDK, the Codex CLI — under your own login, so a tool can plug into whichever subscription its user actually has, and the same code reaches a key when they have one instead.

It is not a trick, and it is not scraping anything. The vendors ship first-party facilities for exactly this. What nobody ships is the pluggability: one calling convention that works whether the person running your tool has a Claude plan or a ChatGPT plan.

Who it is for

Your own local tools. Scripts, dev utilities, one-off automations, the little programs you write for yourself. These are the things where a metered bill is annoying out of all proportion to the value, and where you already have a subscription sitting right there.

Tools you give to other people. This is the audience that shaped the design. If you distribute a local tool — a plugin, a CLI, a desktop app — the typical user has a subscription and no appetite whatsoever for setting up API billing. Asking them for a key is where your install numbers go to die. Letting them point your tool at the plan they already pay for is a different proposition entirely.

That second audience is also the one that cannot absorb a surprise. Somebody running your tool must never wake up to a metered bill they did not agree to. Most of what follows exists because of that sentence.

Not for: hosted services, multi-user backends, anything where one subscription serves many people. See Boundary and compliance.

What it is not

A library with a shared on-disk store, and nothing else. It is not a service, a router, a gateway or a daemon: there is no server component, no routing or load-balancing between connections, and no HTTP face. It does not retry for you — it gives your loop a typed verdict and lets you decide (see Timeouts, retries, and threads). It is not a billing system: guards count tokens from the events a runtime emits, and the vendor's bill is the truth. And it does not do embeddings — no subscription runtime can embed, so the one thing this library is for adds nothing there; embeddings stayed app-owned.

Install

pip install modelpass

Until the first release reaches PyPI, install from source instead: pip install "modelpass @ git+https://github.com/staceyfarias/modelpass.git".

Core has zero runtime dependencies, on purpose. Every runtime, the bench and the LangChain leaf arrive as extras, and modelpass never redistributes a vendor binary — those come through the vendor's own distribution channel under the vendor's own license.

Extra Install What it brings Runtime it turns on
(none) pip install modelpass the store, the CLI, the event and receipt core
anthropic pip install "modelpass[anthropic]" claude-agent-sdk anthropic-sdk (subscription)
openai pip install "modelpass[openai]" openai-codex openai-sdk (subscription)
anthropic-api pip install "modelpass[anthropic-api]" anthropic>=0.40,<1 anthropic-api (metered key)
openai-api pip install "modelpass[openai-api]" openai>=2,<3 openai-api and openai-compatible
google-api pip install "modelpass[google-api]" google-genai>=1,<2 google-api (metered key)
langchain pip install "modelpass[langchain]" langchain-core>=1,<2 the ChatSubpass leaf
bench pip install "modelpass[bench]" flask>=3 modelpass bench

Two of those are the same client pointed at two places: openai-api installs the openai package, which serves both OpenAI's own endpoint and any OpenAI-shaped one. The openai extra is a different package entirely — openai-codex, the agent runtime. Two OpenAI runtimes, two packages, one name each.

The API-key runtimes need nothing beyond their extra and a key. The two subscription runtimes need three things to be true on your machine — the vendor's CLI is installed, you are logged into it, and its version is current enough — and the rest of this section is about them. modelpass supplies none of the three; it drives what you already have.

The vendor CLI, on your PATH

The Python extras pull in each vendor's SDK, but the SDK drives a native CLI binary that you install separately. modelpass resolves it from PATH.

Runtime Install Login Credential lands in
Anthropic npm install -g @anthropic-ai/claude-code claude /login ~/.claude/.credentials.json (macOS: Keychain)
Codex npm install -g @openai/codex codex login ~/.codex/auth.json

On Anthropic, the CLI you install may not be the one modelpass runs. claude-agent-sdk ships a bundled claude binary and resolves it ahead of anything on PATH, so npm install -g @anthropic-ai/claude-code@latest updates the CLI you type and leaves modelpass's untouched. To move modelpass's, upgrade the Python package: pip install -U claude-agent-sdk. You still want the npm install for claude /login and for your own use — the credential store is shared. The preflight receipt names the binary it actually resolved and its version, which is the only reliable way to know which one a version verdict is about.

The login is the vendor's own, in the vendor's own store. modelpass never reads, copies, replays or transmits it — it launches the runtime and the runtime authenticates itself. Log in the same way you would to use the CLI directly, because that is exactly what is happening.

Account profiles and multiple subscriptions

An account with no configDir uses the vendor's normal default storage. An isolated account names its own absolute configDir; modelpass maps that to the vendor's supported root variable for every child process and scrubs any ambient root that the selected account did not name.

For Claude, that variable is CLAUDE_CONFIG_DIR:

Claude Code officially supports side-by-side accounts through CLAUDE_CONFIG_DIR. Each directory contains that account's credentials, settings, plugins and session history. modelpass makes the directory a property of the connection, so selecting a connection also selects the subscription that will pay:

# Log the second profile in once (PowerShell / Windows).
$env:CLAUDE_CONFIG_DIR = "$env:USERPROFILE\.claude-work"
claude /login
Remove-Item Env:CLAUDE_CONFIG_DIR

# Point two named modelpass connections at the two isolated Claude homes.
modelpass connect anthropic --name claude-home `
  --nickname "Anthropic Home" --yes
modelpass connect anthropic --name claude-work `
  --nickname "Anthropic Work" `
  --config-dir "$env:USERPROFILE\.claude-work" --yes

modelpass check claude-home
modelpass check claude-work
# The same thing on macOS or Linux.
CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude /login

modelpass connect anthropic --name claude-home --nickname "Anthropic Home" --yes
modelpass connect anthropic --name claude-work --nickname "Anthropic Work" \
  --config-dir "$HOME/.claude-work" --yes

If an editor extension already launches Claude Code with CLAUDE_CONFIG_DIR, use that same absolute directory for the matching modelpass connection. The preflight receipt prints both configDir and the exact credential-file path it found, before any tokens are spent.

An ambient CLAUDE_CONFIG_DIR is deliberately scrubbed when a connection has no configDir; otherwise launching modelpass from an editor terminal could silently select the editor's account instead of the default account.

Migration note. That scrub is newer than the feature it protects. If your only Claude or Codex login lives in a custom directory that you have been exporting as an ambient CLAUDE_CONFIG_DIR or CODEX_HOME, a connection without a configDir will no longer see it — it now gets the vendor default (~/.claude, ~/.codex). Set configDir on that connection to the same absolute path, or run modelpass connect ... --config-dir PATH, and the receipt will name the directory the child actually receives.

On macOS, Claude Code prefers the login Keychain, and the Keychain entry is keyed to CLAUDE_CONFIG_DIR, so a session with a different directory reads a different entry — directory isolation works there as it does elsewhere. When the Keychain refuses a write (locked, an SSH session, a password out of sync) Claude Code falls back to a plaintext .credentials.json under the same directory, and modelpass reads that file exactly as it does on Linux. modelpass never inspects Keychain contents; when there is no file, the receipt says so and the token-free claude auth status probe — run inside the selected directory — is what answers which account is logged in. A profile whose claude auth status reports no login fails closed before any tokens are spent.

Codex uses the same abstraction with CODEX_HOME. OpenAI documents that all Codex state lives beneath this root, including config.toml and auth.json. The one-time setup is a login with that variable set — nothing else:

$work = "$env:USERPROFILE\.codex-work"
New-Item -ItemType Directory -Force $work | Out-Null
$env:CODEX_HOME = $work
codex login
Remove-Item Env:CODEX_HOME

modelpass connect openai --name openai-home `
  --nickname "OpenAI Home" --yes
modelpass connect openai --name openai-work `
  --nickname "OpenAI Work" --config-dir $work --yes
# macOS / Linux.
CODEX_HOME="$HOME/.codex-work" codex login

modelpass connect openai --name openai-home --nickname "OpenAI Home" --yes
modelpass connect openai --name openai-work --nickname "OpenAI Work" \
  --config-dir "$HOME/.codex-work" --yes

Either Codex credential store isolates correctly. cli_auth_credentials_store defaults to file, and auto falls back to auth.json when a keyring is unavailable — but the keyring entry is keyed by CODEX_HOME too (service Codex Auth, account key the SHA-256 of the canonicalized path, codex-rs/login/src/auth/storage.rs), so a different directory reads a different entry either way. modelpass reports which store is in play as a receipt note rather than requiring one. The default account continues to use Codex's normal default storage.

No part of modelpass has been run on a Mac. The macOS behaviour described here and above — the account profiles, the Keychain reading, the fallback to .credentials.json — is implemented from Claude Code's documentation and Codex's published source, and is covered by unit tests that simulate the platform. Nobody has yet driven modelpass connect, a preflight or a chat on real hardware. If something reads wrong on a real Mac, that is a bug worth reporting rather than a documented limitation.

Prefer a UI? Install modelpass[bench], run modelpass bench, and open the loopback URL it prints. The Flask account manager can add, edit, disable, delete, inspect, and preflight profiles without accepting or displaying a secret. Its playground lets a client select the stable account ID while showing the nickname. The account list itself is offline — it reads your connection file and computes each launch plan, and asks no vendor anything. The token-free identity probe belongs to the two buttons that mean it: Check shows the live receipt, and Verify re-reads the account and pins it. Claude supplies login method, provider, email, organization, and subscriptionType through claude auth status; Codex supplies account type, email, and planType through app-server account/read. modelpass copies only those allow-listed fields into its AccountProfile receipt field and ignores every other vendor field.

On Windows, a repository checkout has a one-step launcher: double-click start.bat or run it from a terminal. It creates .venv when needed, installs the local project with the Bench extra, opens http://127.0.0.1:8765/, and keeps the server attached to that terminal so Ctrl+C stops it. The app binds to loopback only.

There is no Gemini row, because there is no Google subscription to drive. Google's Antigravity terms state that "using third party software, tools, or services to access the Service ... is a breach of this Agreement," with account suspension or termination as the consequence — and unlike Anthropic, whose terms prohibit a specific shape while explicitly covering individual Agent SDK use, Google's sentence has no carve-out for driving Google's own CLI. Do not wire a Google subscription through modelpass or through any other third-party tool.

This is narrower than it sounds, and the asymmetry inside modelpass is deliberate. Google ships a Python SDK, which looks like a contradiction until you notice that these are two products under two agreements: that clause lives in the Antigravity terms, which govern the subscription, while an API key falls under the Google Cloud terms instead — as those same Antigravity terms say. Google's SDK targets the API-key path and cannot authenticate against the subscription at all. So metered Gemini through a key is an ordinary commercial product that the clause does not touch, and it ships here ungated as the google-api runtime, while google-cli and google-sdk — the two subscription identities — stay in the codebase as gated placeholders. Somebody will eventually read that asymmetry as an oversight, which is why it is written down in three places: here, in the capability note (bridge.registry.note(Runtime.GOOGLE_API, Capability.API_KEY_AUTH)), and in docs/legal/google.md. Re-verified against the primary source on 2026-08-31: the sentence is unchanged and there is still no carve-out.

Then tell modelpass about it once, which is the consent step:

$ modelpass connect anthropic
$ modelpass list

Versions — the part that bites

modelpass drives binaries it does not ship, so their versions are yours to manage. Two failure modes follow, and neither is modelpass's to fix:

A CLI too old for the server's current models fails at request validation:

400 ... The 'gpt-5.6-luna' model requires a newer version of Codex.

Zero tokens are spent, but the run does not happen. Vendors retire model families on their own schedule, and an old CLI can end up able to reach nothing at all.

A CLI too old for a feature modelpass wants to use fails silently. Claude Code's prompt-cache TTL variables (CLAUDE_CODE_PROMPT_CACHE_TTL) need v2.1.242 or later; on an older build the variable is passed, ignored, and nothing says so.

Check what you actually have:

$ claude --version
$ codex --version
$ modelpass check <connection>      # re-runs the preflight and prints the receipt

The receipt is where a stale CLI shows up before a run rather than as a confusing 400. Both runtimes name the binary they resolved and its version, plus — on Codex — any different build your config points at. On Anthropic that resolved binary is usually the SDK's bundled one rather than whatever claude --version reports, which is why the receipt is the answer and the shell is not.

The two-installs trap. A vendor's desktop app may install its own auto-updating copy of the CLI somewhere that is not on PATH — Codex does this, under AppData\Local\OpenAI\Codex\bin\<hash>\ on Windows. Your app then works fine on the newest models while codex in a shell is months behind, and modelpass gets the old one. Keep the PATH install current (npm install -g @openai/codex@latest) rather than assuming the app's version is the one in play. modelpass reports the mismatch and deliberately does not resolve it for you: silently switching which executable a chat call launches, because modelpass went looking for a config value, is the ambient influence this project refuses. Point it somewhere specific with options={"codex_bin": ...} if you mean to.

Keeping modelpass's runs out of your own coding sessions

Claude Code stores each session as a transcript under ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl, where the directory name is your absolute working directory with every non-alphanumeric character replaced by -. That is also how claude --continue finds "the most recent session" and how /resume builds its list — both are scoped to the directory you are in.

Which sets up a collision worth understanding. modelpass runs the Claude Code CLI as a subprocess and does not set a working directory, so it inherits your application's. An app launched from a repository you also work in would, if it wrote transcripts, drop agent conversations into exactly the directory your own claude --continue reads from.

Today it writes nothing. Every Anthropic run sets CLAUDE_CODE_SKIP_PROMPT_HISTORY=1 unconditionally, so no transcript is created and none of your sessions gain a neighbour. Verified live 2026-08-30: a run with the variable produced no file anywhere under ~/.claude/projects/, while an otherwise identical control run without it did.

To check for yourself, list that directory before and after a run and look for one named after your app's working directory:

$ ls ~/.claude/projects/

Codex is isolated on two axes rather than one: --ephemeral (also unconditional) means no rollout file, and the adapter defaults its working directory to a fresh temporary one. The Anthropic side relies on the no-writes guarantee alone, which is why the directory it inherits has not mattered so far.

A session is the other case, and it is already shipped. Persistence is the default there — a conversation you can resume is the point of it — so the working directory stops being incidental, and new_chat() defaults to a fresh modelpass-owned scratch directory rather than inheriting yours. Pointing one at a real project folder is something you ask for explicitly with project_folder=, and new_worker() requires it. See Sessions.

What reaches the prompt that you did not write

Worth knowing when a run behaves oddly. Codex reads AGENTS.md from its home directory and from every ancestor of the run's working directory, and injects it as user instructions. It cannot be scrubbed the way MCP servers can, so the preflight receipt names any that apply. Claude Code's equivalents (CLAUDE.md, settings.json) are blocked outright — modelpass runs it with setting_sources=[].

Quickstart

Connect once (this is the consent step, and it prints a receipt before writing anything):

$ modelpass connect anthropic
$ modelpass list

Then, from anywhere. One call, one answer:

import modelpass

bridge = modelpass.Bridge()

answer = bridge.ask("claude-sub", "Explain what a git rebase does.")
print(answer.text)
print(f"[{answer.status} · {answer.receipt.effective_auth_mode} · {answer.usage.total_tokens} tokens]")

ask drains the run for you and returns a frozen Answer — the text, the parsed structured answer when you passed a schema=, the usage, the receipt, the tool calls that happened along the way, and every event, in case you want them later. A run that ends badly raises rather than handing back half an answer: GuardStop, QuotaExhausted, VendorRunFailed.

The same call, streamed, when you want to render the answer as it arrives:

for event in bridge.chat(
    connection="claude-sub",
    message="Explain what a git rebase does.",
):
    if isinstance(event, modelpass.TextDeltaEvent):
        print(event.text, end="", flush=True)
    elif isinstance(event, modelpass.TerminalEvent):
        print(f"\n[{event.status} · {event.auth_mode} · {event.usage.total_tokens} tokens]")

Or with an API key instead of a subscription — same bridge, same events, same receipt, and the billing mode is stamped on the terminal either way:

$ modelpass connect anthropic --api-key-stdin     # pip install "modelpass[anthropic-api]"
for event in bridge.chat(connection="claude-api", message="Explain a git rebase."):
    ...  # identical loop; event.auth_mode on the terminal now reads "api_key"

And the same call on your own event loop. achat, aask and asend take every argument their sync twins take and answer with the same events, receipts, guards and ledger lines — one pre-run pipeline and one event fold underneath, two doors on top:

answer = await bridge.aask("claude-api", "Explain what a git rebase does.")

async for event in bridge.achat(connection="claude-api", message="..."):
    ...

chat, ask and send are unchanged by any of that and are still the whole implementation for a sync caller: an async core with a sync wrapper would have broken every consumer that calls from inside a running loop. Cancelling an async run is aclose() — see Async.

With your own tools

Your functions run in your process — keeping their closures, database handles and UI callbacks — while the runtime runs the model/tool loop inside the one call:

from modelpass import Bridge, ToolDef, ToolCallEvent, ToolResultEvent

def search_notes(args):
    return my_index.search(args["query"])          # your code, your process

tools = [ToolDef(
    name="search_notes",
    description="Search the user's local notes.",
    parameters={
        "type": "object",
        "properties": {"query": {"type": "string"}},
        "required": ["query"],
    },
    handler=search_notes,
)]

for event in Bridge().chat(
    connection="claude-sub",
    message="What did I write about rebasing?",
    tools=tools,
):
    if isinstance(event, ToolCallEvent):
        print(f"→ {event.name}({event.arguments})")
    elif isinstance(event, ToolResultEvent):
        print(f"← {event.content[:80]}")

You observe tool_call / tool_result; you never have to answer one. The runtime executes the loop. (Tools are capability-gated before the preflight, so asking a runtime for something it cannot do costs nothing to find out.)

With a schema

Pass a JSON Schema and the run's final answer is bound to it, using each runtime's own native mechanism — no prompt-and-hope, no parsing prose:

from modelpass import Bridge, StructuredOutputEvent

SCHEMA = {
    "type": "object",
    "title": "Extraction",
    "properties": {
        "status": {"type": "string", "enum": ["found", "partial", "not_found"]},
        "excerpts": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["status", "excerpts"],
}

for event in Bridge().chat(
    connection="claude-sub",
    message="...",
    schema=SCHEMA,
):
    if isinstance(event, StructuredOutputEvent):
        result = event.data          # the parsed object
        print(result["status"], event.valid)

One structured_output event arrives immediately before the terminal, carrying the parsed object (data), the JSON it was parsed from (raw), and valid — modelpass's own structural check, with problems behind a false one. Text deltas still stream as they occur.

Three things worth knowing, all of them deliberate:

  • valid is modelpass's check, not the vendor's. The runtime is what actually constrains the model; valid exists so you can branch without re-validating. A structurally invalid answer is still delivered as-is — modelpass never edits data to make it fit, and it never substitutes an empty dict for a missing answer. A run that produced nothing usable ends status="error" with a reason naming what came back.
  • schema_name= sets the name the schema travels under (default: its title). It matters because on anthropic-sdk the mechanism is a tool call, so if your prompts refer to that tool by name, the name is yours to control.
  • schema= and tools= cannot be combined in v1. The mechanics conflict on openai-sdk and are unverified on anthropic-sdk; refusing on both beats working silently on one.

From LangChain

If your application's LLM seam is already LangChain's BaseChatModel, modelpass.langchain_adapter drops a modelpass connection into it:

from modelpass.langchain_adapter import ChatSubpass

model = ChatSubpass(connection="claude-sub")
answer = model.invoke([HumanMessage(content="Explain what a git rebase does.")])

print(answer.content)
print(answer.usage_metadata)                          # tokens, cache reads broken out
print(answer.response_metadata["subpass_receipt"])    # which connection paid

# Schema-bound output, by the same mechanism as above. The answer arrives on a
# tool_call — the shape a forced tool choice over an API key produces — rather
# than as a parsed object, because building the object is the caller's job.
decision = model.bind_tools([MySchema], tool_choice="MySchema").invoke(messages)
decision.tool_calls[0]["args"]

ainvoke and astream run over achat rather than putting the sync path in an executor.

It is a convenience adapter, not the product's surface. It covers modelpass's chat-shaped subset — stateless chat and schema-bound output — and nothing else: runtime-executed tools with their call/result pairs, MCP, cancellation and the run log have no BaseChatModel shape, and bending them into one would mean inventing behavior. Bridge.chat stays the real API. modelpass is not tied to LangChain; this is an optional leaf over the same public bridge, and core still imports nothing from LangChain.

Two things the adapter has to say out loud: stop sequences are ignored, and a vendor failure — which modelpass reports as a terminal event, not an exception — is raised as SubpassRunError (SubpassGuardStopError / SubpassQuotaExhaustedError for the two a caller can act on, SubpassTimeoutError when a bound expired), carrying the receipt, the partial text and what was spent. temperature, top_p, top_k, max_tokens and reasoning_effort are passed through rather than warned about — they become a Sampling, the runtime takes what it takes, and response_metadata gains subpass_sampling_applied and subpass_sampling_notes saying what happened to the rest.

Connections

A connection is the unit of consent and the unit of configuration. An application built on modelpass stores a connection name and nothing else, and its user configures their subscription or their key once, in one place, for every modelpass tool on the machine.

The four ways to point one at a model

Each of these prints a receipt and asks before it writes anything:

# Subscription. The runtime's own login pays for it and
# nothing is stored anywhere but the connection itself.
modelpass connect anthropic

# Metered, on the API runtime, reading a named variable.
# The variable's name is stored; its value never is.
modelpass connect anthropic --runtime anthropic-api --api-key-env ANTHROPIC_API_KEY

# Metered, on the API runtime, with the key handed over
# once and written to the secrets file described below.
cat key.txt | modelpass connect anthropic --api-key-stdin

# An OpenAI-shaped endpoint that is not OpenAI -- a local
# Ollama, say -- names its vendor `compatible` and its URL.
# With no key flag at all, because a local box authenticates
# nobody and saying so beats pointing at a variable nobody sets.
# (--api-key-env / --api-key-stdin still work, for a gateway.)
modelpass connect compatible --base-url http://localhost:11434/v1 --model llama3.1

# ...and then the step this runtime cannot skip. `openai-compatible`
# names an API *shape*, so its capability row is `unverified` by
# construction -- including `chat`, which means bridge.chat()
# refuses until somebody has driven the endpoint. This drives it:
# one short chat, one tool round trip, one structured-output call,
# and it records which of those actually worked on the connection.
modelpass verify compatible-api

verify is the only command that spends in order to learn something, and it says so before it does. Against a local Ollama that is electricity; against a metered gateway it is a few hundred tokens. What it writes is a per-connection record — two installs of this runtime can legitimately disagree about every cell, so the verdicts live on your connection and never in the shared table. Cells three short calls cannot decide (thinking, interim_usage, the sampling pair) stay where the table left them, and the output says so. Details in docs/api-and-runtimes.md §2.0c.

Omitting --runtime keeps the defaults unchanged: a bare connect is the subscription, and --api-key-stdin on its own still moves to the API runtime. --api-key-env on its own still means metered billing on the agent runtime, because an environment variable does reach a launched child — which is why the combination needed a flag to say otherwise rather than a change to what it already did.

~/.modelpass/connections.toml

Written by modelpass connect, safe to edit by hand, and it contains no secrets — only pointers, validated on the way in and on the way out.

version = 1

# --- store-wide settings (optional; omit the table entirely for defaults) ----
[settings]
# Every completed run appends one line to runs.jsonl beside this file. On by
# default; this is the opt-out.
runLog = true

# --- a subscription connection ----------------------------------------------
[connections.claude-sub]
runtime       = "anthropic-sdk"      # anthropic-sdk | openai-sdk
authMode      = "subscription"       # subscription | api_key
credentialRef = "native-login"       # the runtime's own login store
nickname      = "Anthropic Home"     # optional human-facing label
# configDir omitted: use this vendor's default account storage
model         = "sonnet"             # optional; omit for the runtime's default
description   = "Claude Code login"  # optional note to yourself
enabled       = true                 # optional, default true (see below)
groups        = ["fast"]             # optional; omit and it is in "default"
timeoutSeconds = 120                 # optional; default wall-clock bound per call
retry         = "default"            # optional; "default" | "never" (see below)
maxInputTokens = 200000              # optional; the model's usable input window, in
                                     # tokens, as YOU state it. Omit it and the window
                                     # is unknown -- there is no table of model sizes
                                     # here and nothing guesses one. Nothing in
                                     # modelpass reads it; it is for the tools that do.
promptCache   = "default"            # optional; ask for prompt caching. "default" =
                                     # the vendor's own lifetime, or name one the
                                     # runtime accepts. Omit it and nothing is stated
                                     # -- which is NOT "caching off" (see below).

  # Spend guards. NO DEFAULTS -- see "Guards" below. A connection with no
  # [guards] table genuinely has nothing bounding one run's spend, and the file
  # says nothing rather than writing a misleading "warnAtTokens = 0".
  [connections.claude-sub.guards]
  warnAtTokens = 150000
  stopAtTokens = 400000

    [connections.claude-sub.guards.onQuotaExhausted]
    action = "stop"                  # stop | failover

# --- a metered connection, for comparison -----------------------------------
[connections.claude-api]
runtime       = "anthropic-sdk"
authMode      = "api_key"
credentialRef = "env:ANTHROPIC_API_KEY"   # the NAME. Never the key.
# Non-credential variables this connection may see through the scrub. A closed
# per-runtime set: a credential can never be added here.
allowEnv      = ["ANTHROPIC_BASE_URL"]
groups        = ["fast", "metered"]       # a connection may be in several

# --- a connection that fails over when the allowance runs out ---------------
[connections.codex-sub]
runtime       = "openai-sdk"
authMode      = "subscription"
credentialRef = "native-login"
nickname      = "OpenAI Work"
configDir     = "C:\\Users\\you\\.codex-work"

  [connections.codex-sub.guards.onQuotaExhausted]
  action   = "failover"
  failover = "claude-api"            # naming it here IS the consent

credentialRef forms. It says where a credential lives, never what it is:

Form Meaning
native-login The runtime's own login store (claude /login, codex login). The subscription path.
env:NAME The environment variable called NAME. The name is stored; the value never is.
secret:entry An entry in ~/.modelpass/secrets.toml. The entry name is stored; the value lives in the other file. See API keys below.
keychain:entry A keychain entry by name.

Anything that looks like a secret is refused outright — paste an sk-... into a credentialRef and modelpass raises CredentialRefIsSecret rather than storing it.

API keys, in a second file

An explicit key goes in a second file, ~/.modelpass/secrets.toml, beside connections.toml and never inside it. The connection file is the one that is safe to share and commit; one key in it would destroy that property for everybody, including people who never wanted an explicit key. So the pointer lives in the shareable file and the value lives in the other one:

# ~/.modelpass/secrets.toml -- NOT shareable.
version = 1

[secrets.claude-api]
apiKey = "..."

Writing one. The key is read from standard input and never from the command line:

# The key arrives on stdin. Nothing is written until the receipt is shown.
cat key.txt | modelpass connect anthropic --api-key-stdin

There is deliberately no option anywhere in modelpass that takes a key as a value: a command line is visible in process listings, is written to shell history, and is echoed by CI logs. --api-key-stdin also refuses to read from a terminal, and tells you how to pipe instead. Exactly one trailing newline is stripped — what echo, a here-string and a password manager's --raw each append — and an empty value is refused.

--api-key-stdin selects the vendor's API runtime (anthropic-api, openai-api), because that is the one that bills a key you hand over. A stored key cannot reach the child process that anthropic-sdk or openai-sdk launches, so secret: is refused on those runtimes rather than accepted and quietly ignored; use env:NAME there.

Permissions, stated honestly. On macOS and Linux the file is created 0600, which a test asserts rather than assumes. If the mode is later widened so that group or other can read it, modelpass check says so and tells you the chmod — it does not refuse, because the file is still yours and refusing would only lock you out of your own configuration.

On Windows there are two layers and modelpass claims only what it applied:

  • The documented floor is the ACL your user profile directory already carries, which %USERPROFILE%\.modelpass inherits.
  • On top of that, once at creation, modelpass runs icacls <path> /inheritance:r /grant:r "<you>":F and records whether it worked. modelpass check reports either "restricted to your account" or "could not restrict: <reason>; the profile directory's own ACL is the floor". A protection that was not applied is never described as though it were.

Keeping the two files in step. They are not written atomically together, so the order is part of the design: the secret is written first and the connection second when adding, and the connection first and the secret second when deleting. A crash in between leaves an unreferenced secret — inert, and listed by modelpass check — rather than a connection pointing at nothing.

  • Deleting a connection removes its secret entry only when the entry is named after that connection and nothing else references it. Otherwise the entry stays and you are told. An orphan is recoverable; a deleted key is not.
  • Deleting a secret that a connection still references is refused, naming them.
  • modelpass rename <old> <new> moves the connection and, under the same rule, moves its secret entry with it. When the entry is shared, or is not named after the connection, it stays where it is and the command says so.
  • modelpass check lists orphan entries — entries no connection references. They are reported and never collected for you.

No secret value ever reaches a receipt, the run log, a to_dict() or a vendor event; a sha256: fingerprint of it may, and that is what the receipt's account field carries on a key-backed connection.

The rest of the keys, and the compatibility policy

guards — thresholds in tokens, per run. There are no defaults. A missing [guards] table means nothing bounds that connection's spend, and modelpass makes that absence loud rather than papering over it with an invented number. See Guards.

onQuotaExhaustedstop (clean stop, the only default) or failover to a named connection. Failover is the only path in modelpass that can move a run onto metered billing, and naming the second connection is the consent. It never chains, never self-targets, announces itself as a failover event carrying the second connection's own receipt, and the terminal event names both connections.

allowEnv — a closed, per-runtime set of non-credential variables that may survive the scrub (currently: ANTHROPIC_BASE_URL, on anthropic-sdk, and nothing else). Deliberately not a free-form passthrough: the first thing anyone would put in one of those is an API key.

nickname — an optional human-facing label. It can contain spaces and can be renamed safely. Clients, failover targets, receipts, and run logs continue to use the stable table key (claude-sub, codex-sub, and so on).

configDir — an absolute vendor configuration directory. On anthropic-sdk it is passed as CLAUDE_CONFIG_DIR; on openai-sdk it is passed as CODEX_HOME. Omitting it uses the vendor default. An unconfigured ambient root is scrubbed. Both vendors key their OS credential store to this directory — Claude Code's Keychain entry and Codex's keyring entry are per-directory — so isolation holds on every platform, and no credential-store setting is required of you. Keychain contents are never inspected by modelpass; on macOS claude auth status supplies the identity instead.

enabled — default true. A disabled connection is still listed and still preflights; what it refuses is runs, with a clear ConnectionDisabled error raised before anything is attempted. It exists so that taking a connection out of service is not "delete it and retype it later", which is how you lose the guard thresholds you worked out.

groups — the groups this connection is in. Omit it and the connection is in default, which is simply the name for the connections that name no group: one with groups = ["fast"] is in fast and is not also in default. A group needs no creating and cannot be deleted — it exists while some connection claims it, so moving the last member out is how it goes away. See Groups.

Compatibility policy (2026-09-13). Several tools on one machine share this file, so they will not all be the same build of modelpass. What every build promises:

  • The version above bumps only when an existing key changes meaning or disappears. New keys and new runtime values are additive and do not bump it. The file stays at version 1 across the API-runtime extension.
  • A build reads any version. It refuses to write a file whose version is newer than its own, naming both versions and saying a newer build is needed. Reading a newer file never leaves modelpass accounts or modelpass check unable to say anything.
  • A key this build does not recognise is preserved exactly as written and reported — never a failure, and never dropped when the file is rewritten. That applies inside guards, onQuotaExhausted and verifiedIdentity too.
  • An unknown runtime or authMode refuses that connection, with the values this build does know named, and leaves every other connection usable. The refused connection is still written back untouched; modelpass accounts says which ones and why.

Anything else invalid — a warn threshold above its stop, enabled = "no" — still refuses the file. Those are mistakes in a file a human edited, and quietly dropping the connection would hide one.

MODELPASS_HOME moves the config location ($MODELPASS_HOME/connections.toml instead of ~/.modelpass/connections.toml). It moves where the file is read from and nothing else — it can never turn an ambient credential into a connection. SUBPASS_HOME is still honoured when MODELPASS_HOME is unset, with a deprecation warning, until 0.3.0.

Per-app configuration

An application built on modelpass should store a connection name and nothing else:

# your-app's own config
[ai]
connection = "claude-sub"

Not a runtime, not an auth mode, and certainly not a credential. The connection is the unit of consent, it lives in one place, and every modelpass tool on the machine reuses it. Users configure their subscription once.

A group works in the same slot, and is the better answer when your app wants "whichever of these the user has" rather than one named connection:

[ai]
connection = "group:cheap"

Groups: addressing a set of connections

A connection may declare groups, and a group may be named anywhere a connection name is expected:

[connections.local-ollama]
runtime       = "openai-compatible"
authMode      = "api_key"
baseUrl       = "http://localhost:11434/v1"
credentialRef = "none"
groups        = ["cheap", "offline"]
answer = bridge.ask("group:cheap", "Classify this: ...")

Three things are worth knowing before you use one.

default means "the ungrouped ones". Not "all of them". A connection you put in fast leaves default; say groups = ["fast", "default"] if you want both. The other reading would make default carry no information and group:default mean "any connection at all".

A group resolves to the first enabled member in name order, and modelpass does not pretend you chose that rule. bridge.select("cheap") hands back the connection that would run and every member it walked past with the reason, and modelpass groups prints the same thing:

  cheap
      a-gateway   [disabled]
      local-ollama
      -> group:cheap resolves to local-ollama
         a-gateway passed over: disabled

If you need a particular connection, name that connection. A group is a convenience for "any of these", not a router with a policy — preference order within a group does not exist yet, and it is queued with throttling and metrics rather than guessed at.

A group is not a thing you create. There is no [groups] table; a group exists while some connection claims it. Nothing is left behind pointing at nothing, and there is no second place a connection can be taken out of service — enabled = false remains the only one. A group whose every member is disabled raises GroupUnavailable, which is a different error from NoSuchGroup because it sends you somewhere different.

What a run is billed to is always a connection. The group is how the call was addressed; the receipt, the terminal event and the run-log line name the connection that was actually reached.

One field refuses a group: onQuotaExhausted.failover. Failover is the only path onto metered billing, and naming the target is the consent — a group's membership can change after that consent was given, so a target added to the group next month would be paid for under a decision nobody made about it. Name the connection you mean. The refusal happens when the connection is constructed, not when an allowance runs out.

On the command line: modelpass groups lists them, modelpass list --group fast filters the listing, and modelpass set-groups <name> fast cheap moves a connection (naming no group returns it to default). In the bench, the Groups page shows the same resolution, and each account's form has a groups box.

What the receipt tells you

The receipt answers what is about to happen, and who pays for it. It exists before any run and again at setup time, and nothing is written or spent before you have seen one. receipt.summary() prints it; the fields are on the object.

Field What it answers
effective_auth_mode, requested_auth_mode, detected_auth_mode which billing mode is really about to be used
credential_source, account, plan_name, account_profile, identity_verified whose it is — a login, a named variable, a stored entry, or a sha256: fingerprint of a key, never a key
runtime, runtime_available, binary, model, model_source what will run, and where the model came from
scrubbed, preserved, passthrough, config_dir, directives what the child process will and will not see, or on an API runtime that the client is constructed with an explicit key and the SDK never reads the environment
guards_configured, retry what bounds the run, including a retry = "never" stance
sampling_requested, sampling_applied, sampling_notes what you asked for, what is actually being sent, and one sentence per field dropped or coerced
cache_breakpoints_requested, cache_breakpoints_honoured whether the cache markers you set reach the vendor
cache, ok, problem, notes the caching disclosure, whether this would run at all, and anything else worth saying

What one looks like

$ modelpass connect anthropic
claude-sub
  runtime      anthropic-sdk
  auth mode    subscription
  credential   Claude Code login (~/.claude/.credentials.json)
  plan         Max 5x
  scrubbed     ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, CLAUDE_CODE_USE_BEDROCK
  guards       no spend guards configured: nothing bounds what one run may spend...
  status       OK

Save this connection as 'claude-sub'? [y/N]

Nothing is written before you have seen that. The scrubbed line is the interesting one — see the next section.

A scrubbed launch

This part matters more than it sounds like it should, because the vendor runtimes scavenge ambient credentials, and two of the three fail toward metered billing:

  • Claude Code resolves subscription OAuth last — behind ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, apiKeyHelper and the cloud-provider switches. A stray key in your environment silently wins, and you find out on the bill.
  • The Codex SDK injects CODEX_API_KEY into the CLI's environment, and OpenAI's own CI documentation encourages setting it.

So declining to set a key is not enough. modelpass computes the child process's environment explicitly, removes every credential the connection did not name, and reports what it removed. ANTHROPIC_BASE_URL is on that list too — not a credential, but it silently changes where a subscription token gets sent, and nothing ambient may shape a run.

A normalized event stream

One call, one stream of events: text deltas, thinking, usage in tokens, guard warnings and stops, tool calls and results, a schema-bound answer if you asked for one, and a terminal event. Anything a runtime reports that the vocabulary does not cover arrives as a vendor_event rather than being dropped.

A stamped terminal, and a durable record

Every stream that completes normally ends with exactly one terminal event, stamped by modelpass — not by the adapter — with the connection name and the auth mode actually used. Adapters can report a status; they cannot claim how a run was billed.

That stamp is also written to ~/.modelpass/runs.jsonl, one JSON line per connection the call ran on, so the question survives the run that answered it. (A call that exhausts its allowance and fails over spent two allowances, and writes two lines — one per leg, each with the auth mode that paid for it.) See How do I know it was the subscription? — which includes a live falsification experiment, its control, and the check that does not require trusting modelpass at all.

Guards, and where the responsibility sits

Guards are warnAtTokens / stopAtTokens per connection, in tokens, per run. Full treatment: docs/guards.md.

modelpass ships no default thresholds, and that is deliberate. An earlier draft had 200k warn / 1M stop. Neither number was derived from a plan's allowance, measured against a real run, or sourced from a vendor — and a made-up number presented as a product default reads as advice this project is in no position to give. It does not know your plan, your month, or what you are about to ask for.

What replaces the default is making the absence visible: the receipt says no spend guards configured, modelpass connect prints it at the moment you form your picture of what you just set up, modelpass list shows guards: none, and the bench tags it. Silence about spend was the failure mode worth designing against.

Three things guards do not claim:

  • The vendor's bill is the truth. Local accounting is best-effort token counting from the events a runtime chooses to emit. Nothing here is a billing system.
  • stopAtTokens is not a hard ceiling. It stops the run as soon as modelpass is in a position to know. On the four API runtimes the tool loop is in this process and usage arrives with every response, so a guard is checked between rounds; on openai-sdk that holds on the default transport and not on options={"transport": "exec"}, where the only usage arrives at the end. The capability table's interim_usage row is the per-runtime answer.
  • There are no dollar thresholds, decided rather than deferred — and re-examined on 2026-09-13, when four of the six configurable runtimes became metered, without changing. A price is a fact about your contract rather than about the response, and openai-compatible has no price modelpass could know at all. The unit stays tokens, which every runtime reports.

For metered (api_key) connections, set a vendor-side hard cap — enforced where the billing actually happens, not here. A guard is an observation modelpass acts on; a cap is enforcement, and it is still true when this library is not in the call path. docs/guards.md links the place to set one per vendor.

Do that in addition to token guards, not instead of them.

Capabilities per runtime

These runtimes are not interchangeable, and modelpass reports the differences rather than flattening them. unverified means nobody checked — it is not a soft yes, and modelpass treats it as unusable.

Eight runtime identities, because capability identity is the runtime, not the vendor (D6):

Runtime Family State
anthropic-sdk agent implemented, validated live
openai-sdk agent implemented, validated live
anthropic-api API key adapter landed; ttl_control and midconversation_system await a live drive
openai-api API key adapter landed; thinking awaits a live drive
google-api API key adapter landed; thinking awaits a live drive
openai-compatible API key, or none adapter landed; every cell is per install, filled by modelpass verify
google-cli agent gated placeholder — see the Google note under Install
google-sdk agent gated placeholder — same reason

"Awaits a live drive" is literal. Those cells moved as far as a fake transport and the installed SDK's own typed surface can move them; the half that needs a real call to a real endpoint is a live-marked test the owner runs, not something a changelog may assert. The four live files and the variables they need are listed once in docs/api-and-runtimes.md.

The table below is the short form for the two agent runtimes — nine rows of twenty-five.

Capability anthropic-sdk openai-sdk
chat, streaming, usage_tokens supported supported
thinking supported supported — but what arrives is the reasoning summary stream (item/reasoning/summaryPartAdded / summaryTextDelta), not raw reasoning text: item/reasoning/textDelta exists in the protocol and did not fire even at effort: "high" with summary: "auto" (driven 2026-08-31). Do not promise your users "the model's reasoning". unverified on options={"transport": "exec"}, where nobody has checked either way
tools_in_process — your Python functions as tools supported supported since 2026-08-31, when the default transport became codex app-server: each tool is registered as a dynamicTools entry and your function answers the server's item/tool/call in-process, driven live and then end-to-end through bridge.chat(tools=[...]). The vendor marks that surface experimental. Unsupported on options={"transport": "exec"}, whose only tool channel is MCP over a command line — a checked absence, not an open question.
mcp_servers — per-run MCP declaration supported, and exclusive: only the servers you name are reachable unsupported on the default transport as of 2026-08-31, and this is the one thing the app-server flip cost: per-run MCP servers have no driven equivalent there. Works on options={"transport": "exec"}-c mcp_servers.<name>={...}, with exclusivity via enumerate-and-disable (fails closed if enumeration fails). Nothing about the vendor changed; the default did.
interim_usage — usage while the run is still going supported — per assistant turn, so a guard can stop a tool loop between rounds supported since 2026-08-31thread/tokenUsage/updated arrives after each model response, mid-turn. So stopAtTokens on Codex is a real circuit breaker now, not a post-hoc report. Unsupported on options={"transport": "exec"}, where codex exec --json reports once, at the end.
graceful_cancel supported (interrupt()) unverified; the floor is terminating the process. modelpass sends turn/interrupt on the app-server transport — from cancel() and from an abandoned session turn — and a live drive on 2026-08-31 ended a turn with status: "interrupted". The cell still does not move, because the half a caller would rely on is unchecked: modelpass does not wait for the terminal that interrupt produces, and nobody has shown a thread taking another turn afterwards
structured_output — a schema-bound answer supported, native (output_format--json-schema), and it accepts a loose schema: optional properties, no additionalProperties supported, native — a per-turn outputSchema field on the default transport (no temp file at all) and codex exec --output-schema on the opt-out. Expect the Responses API's strict subset either way — every property in required, additionalProperties: false everywhere. That constraint is driven on exec and not re-driven on app-server, so modelpass predicts it on both rather than claiming it
sampling_controls, max_output_tokens unsupported — a checked absence: no temperature, top_p, top_k or output-length parameter exists on either CLI protocol. Passing sampling= is still not an error; see Sampling controls unsupported, same evidence
subagents supported unsupported
sessions_resume supported supported
sessions_list — enumerate stored conversations supported, scoped by working directory supported since 2026-08-31thread/list on the default transport: token-free, paginated, account-wide. Unsupported on options={"transport": "exec"}, which has no scriptable listing at all, only an interactive picker — and is refused there rather than answered empty, because an empty list would be a false statement about an account that may have hundreds.
system_prompt_replace — a ChatSession with your persona instead of the runtime's supported supported since 2026-08-31baseInstructions genuinely replaces Codex's coding-agent persona on the default transport. See the behaviour change below: this changes runs you were already making. Unsupported on options={"transport": "exec"}, where a system prompt is layered on top of the persona. A WorkerSession keeps the layering on both, because append is the semantics a worker asked for.
get_history() on a session — read the conversation back supported (persist=True) supported on the default transport, paged from thread/items/list, with tool calls and compactions marked in place rather than dropped. () on options={"transport": "exec"}, honestly: codex exec cannot read a thread back and modelpass will not reassemble one from the events it happened to see.

This table describes the default transport on openai-sdk, which since 2026-08-31 is codex app-server; options={"transport": "exec"} narrows six of those cells and widens mcp_servers, in the way the tools_in_process row spells out. docs/api-and-runtimes.md carries the full matrix and says which cells move with the transport.

Query it in code: bridge.registry.support(runtime, Capability.TOOLS_IN_PROCESS), or bridge.find(capability="tools_in_process") to pick a connection that can do the thing. On an openai-compatible connection ask bridge.registry_for(connection) instead: that runtime's cells live on the connection a drive wrote them to and never in the shared table, because two installs of it can legitimately disagree about every one.

A behaviour change on Codex (2026-08-31)

openai-sdk now runs codex app-server by default. It used to run codex exec. Same runtime identity, same connection, same openai-sdk name — and two things a caller can feel, so they are stated here rather than left in a capability note:

  1. system_prompt replaces instead of layering. On the default transport a system prompt becomes ThreadStartParams.baseInstructions, which goes in instead of Codex's built-in "deployed coding agent" persona. codex exec put it on a System: line on top of that persona. If you pass system_prompt= to chat() or open a ChatSession on a Codex connection, your runs changed: several thousand tokens of vendor framing are gone and the model is no longer told it is a coding agent. For most callers who brought their own persona that is the better run — it is the whole reason system_prompt_replace is now supported — but it is a change, not a fix.
  2. mcp_servers= is refused on the default. Per-run MCP declaration and its enumerate-and-disable exclusivity are codex exec features with no driven equivalent on app-server.

The escape from both is one line:

bridge.chat(connection="codex-sub", message="...", options={"transport": "exec"})

exec is fully supported, unchanged, and not on a removal path. Every receipt names which transport actually ran.

One caveat worth carrying into your own copy. Replacing the instructions does not by itself stop Codex behaving like a coding agent. baseInstructions replaces the base instructions and nothing else — tool definitions, environment context and AGENTS.md are untouched, which is why ~12.4k input tokens survive a replacement that removed ~3,543 — and a model reads its identity off its toolbelt as much as off its prompt. Switching the toolbelt off is a separate switch, and since the change below every chat-shaped call throws both. Say "your persona instead of Codex's framing", not "Codex stops being a coding agent".

Codex chat calls no longer carry Codex's toolbelt (2026-08-31)

A second change the same day, and this one is a repair rather than a consequence. bridge.chat() has always documented that "built-in tools are off by construction here, so tools= means the caller's own functions and nothing else." anthropic-sdk kept that promise. openai-sdk did not: every stateless call ran with Codex's whole coding-agent belt attached — exec, exec_command, write_stdin, apply_patch, web.run, image_gen and eight more — on both transports.

It now switches them off, and the difference is measurable: an otherwise identical bare call fell from 15,890 to 10,269 prompt tokens — 5,621 removed, 35.4% of the prefix (driven live 2026-08-31 on codex-cli 0.151.0). Two things to know:

  • Your own tools= are unaffected. Driven in the same session: the caller's dynamicTools still registered, the handler still ran in-process, the model still answered from its result. Switching off the runtime's toolbelt does not switch off yours.
  • If you were relying on Codex having a shell during a chat() call, opt back in with options={"native_tools": True}. A WorkerSession keeps the belt by design — iterating with exec is what a worker is for.

Two things this does not mean. Codex is still not cheap next to Claude: a bare call is ~10.3k tokens against ~2.4k, because what remains is environment context and protocol overhead that no config key reaches. And "toolbelt off" means the toolbelt modelpass can reachunified_exec is a stable feature that both -c and --disable fail to switch off.

One asymmetry worth reading twice, because it changes what you can promise your users:

  • Interim usage is what makes a stopAtTokens a circuit breaker rather than a report. It is a circuit breaker on both runtimes as of 2026-08-31 — and a report again on Codex the moment a call passes options={"transport": "exec"}, which the receipt says on that run.

Retired 2026-08-31. This section used to carry a second asymmetry: that built-in tools stay off on anthropic-sdk but that this "is not achievable on openai-sdk — Codex has no tools=[] equivalent and its shell tool is core to the runtime." That was wrong, and believing it is what let a stateless Codex chat() keep a shell for as long as it did. Codex has no tools=[], but it has a config layer: six -c features.* / web_search keys switch the belt off and take 5,621 prompt tokens off the wire with it. A chat call on either runtime now cannot suddenly read the filesystem. command_execution items are still mapped to ordinary tool events with server "codex", which is what a WorkerSession — or an opted-in native_tools=True call — produces.

  • The structured-output schema subset differs. A schema derived from a Python dataclass with defaults works as-is on anthropic-sdk and is rejected by openai-sdk with a 400 — before any generation, so it costs nothing to discover, but it will not run. modelpass.schema.to_openai_strict(schema) converts one; openai_strict_issues(schema) tells you whether you need to, and the OpenAI preflight receipt says so too. Both constraints were captured from live rejections on 2026-08-17 (the verification record).

Prompt caching

Two different questions live here, and conflating them is how a consumer ends up paying full price on every call without knowing. ttl_control asks how long a cached prefix lives. cache_breakpoints asks whether you get to say where the cacheable prefix ends — an Anthropic cache_control marker on a content block. A runtime can have one, both or neither.

Pass a marked prefix as content blocks:

from modelpass import CacheControl, TextBlock

events = bridge.chat(
    connection="claude-sub",
    system_prompt=(
        TextBlock("You are a careful reviewer."),
        TextBlock(long_stable_corpus, CacheControl(ttl="1h")),
        TextBlock(todays_variable_tail),
    ),
    message="Review this.",
)

A plain string is unchanged in every respect — same bytes, same rendering, same receipt — so nothing about a call you already have moves.

Runtime Explicit breakpoints What caching you do get
anthropic-sdk unsupportedClaudeAgentOptions.system_prompt is str | SystemPromptPreset | SystemPromptFile, with no block list anywhere (read off claude_agent_sdk 0.2.148, 2026-09-13) the CLI caches on its own, and ttl_control is supported: a session pins 1h
openai-sdk unsupported — no cache_control in the Responses API, and baseInstructions is a string automatic, exact-prefix, ~5–10 minutes idle. Prefix stability is the whole of the control
anthropic-api supportedcache_control markers reach messages.create intact, and this is the runtime the content-block vocabulary exists for. ttl_control is unverified: a 5m/1h lifetime is carried through, but no test in this suite can observe one expiring explicit, where you put the breakpoints
openai-api, openai-compatible unsupported — no such field in the request schema automatic, vendor-side
google-api unsupported — Gemini's explicit caching is a separate cached-content resource, not a marker inside a message implicit caching, automatic

Where breakpoints are not honoured they are not silently dropped. The content is flattened to text and the receipt that precedes the run carries the line cache breakpoints were requested and dropped: <runtime> does not accept them, alongside two counts you can also read from the run log:

receipt.cache_breakpoints_requested   # what you asked for
receipt.cache_breakpoints_honoured    # what reached the vendor

Two numbers that disagree is a run that paid for a prefix it thought it had cached. That is the failure this exists to end: three consumers were building four-breakpoint prompts and having the markers stripped at the door, with nothing anywhere reporting it.

Sampling controls

temperature, top_p, top_k, max_output_tokens and reasoning_effort are request fields on Bridge.chat, carried by one frozen Sampling object:

from modelpass import Bridge, Sampling

for event in bridge.chat(
    connection="claude-key",
    message="Summarise this in one sentence.",
    sampling=Sampling(temperature=0.0, max_output_tokens=256),
):
    ...

The honesty rule: modelpass sends what the model takes, and the receipt says what it sent. Support varies per model, not only per runtime — GPT-5 accepts only temperature=1.0, several Anthropic 4.x models accept one of temperature or top_p but not both, the o-series accepts none of them, and an Anthropic model asked to think adaptively takes no sampling parameters at all. A capability cell cannot express that, so it does not try: the cell says whether the runtime's request schema has the field, and every call carries its own report.

Three fields on every receipt, and on every line of runs.jsonl:

Field What it holds
sampling_requested what you asked for, in modelpass's field names
sampling_applied what is actually being sent
sampling_notes one sentence per field that was dropped or coerced
receipt = bridge.preflight("openai-key", model="gpt-5", sampling=Sampling(temperature=0.2))
receipt.sampling_applied   # {'temperature': 1.0}
receipt.sampling_notes     # ('temperature 0.2 requested; gpt-5 accepts only 1.0, sent 1.0',)

It is a pre-run report, from modelpass/sampling_rules.py rather than from a vendor, so you can read it before you spend. A field the wire protocol requires and you did not set is disclosed too: an anthropic-api call with no ceiling carries a 4096-token one, and the note says so rather than leaving it to be discovered in a truncated answer.

On a subscription runtime, sampling is dropped — and named. Passing sampling= to anthropic-sdk or openai-sdk is not an error. Every field is dropped, every drop gets a sentence, and the run proceeds. That is deliberate: refusing would be the right answer for a tool a runtime cannot run, and the wrong one here, where "send what it takes and say what happened to the rest" is available.

max_output_tokens is not stopAtTokens. The first is a ceiling on one answer, which the vendor enforces by truncating. The second is a modelpass guard over a whole run's total spend, which ends the stream. Callers who want shorter answers want the first; callers who want a bounded bill want the second; set both if you want both.

An unknown model gets the runtime's defaults and a note saying so — modelpass never guesses a model into a family, because guessing that something reasons sends a parameter the vendor rejects and the call stops working, while guessing the other way costs one line of disclosure.

Timeouts, retries, and threads

Three contracts that used to be four apps' worth of workarounds. Added 2026-09-13 (ticket 1.12).

A call can be bounded

from modelpass import Timeout

answer = bridge.ask(
    "claude-sub", "Score this posting: ...",
    timeout=Timeout(total=120, first_token=20),
)

total bounds the whole run's wall clock. first_token bounds the narrower wait for the first event that is not the receipt — the is anything happening at all question, which on a subprocess-driven runtime is what separates a slow answer from a runtime that never came up. A bare number is total.

When a bound expires, modelpass cancels the run through the adapter's own cancel() hook and the stream still ends the way every stream ends: one terminal event, stamped, carrying the tokens spent before the bound ran out, with status = timed_out. A timed-out run is still a billed run, and the terminal is where you find out what it cost. ask() and chat(raise_on_stop=True) raise RunTimedOut instead; the LangChain leaf raises SubpassTimeoutError.

A connection can carry a default with timeoutSeconds. A call that passes its own timeout= replaces it rather than being clamped by it.

Cancelling by closing the iterator is unchanged and still the documented way to stop a run early (D10). It cancels exactly once, with or without a bound configured.

modelpass never retries for you

Say that part first, because it is the part people assume otherwise. There is no retry loop in this library, no backoff, and no configuration for either. What modelpass provides is the verdict:

for event in bridge.chat(connection="claude-sub", message="..."):
    if isinstance(event, TerminalEvent) and event.status is not TerminalStatus.OK:
        if event.retryable is Retryable.YES:
            schedule_again(after=event.retry_after)

Retryable has three values and not two, because we do not know is a real answer and a boolean has to guess which way to lie:

verdict what it means
YES transient: a 5xx, a 429 that named a retry-after, a connection reset, a first_token timeout on a stateless run
NO deterministic: a caller mistake, auth, a guard stop, a spent allowance with no failover, a content filter, a rejected schema, a truncation
UNKNOWN the vendor reported nothing typed to classify it by

Every error modelpass raises carries the same verdict on .retryable, and .retry_after where the vendor named a wait. That includes the errors ask(), aask() and chat(raise_on_stop=True) raise in a terminal event's place: each one carries that terminal's verdict — .retryable, .retry_after, .status_code, and the event itself on .terminal — so a 429 reaches a retry loop as a 429 rather than as an unclassified failure.

The verdict is computed from typed fields only — status codes, exception classes, terminal statuses — and never from the text of a message. That rule is not fastidiousness. A consumer once classified by substring-matching the message, "500" matched inside "stopAtTokens threshold 500000 reached", and a deterministic guard stop was retried four times against a live subscription allowance. The table lives in one module, modelpass/retry.py, with a dated note on every row.

A connection can say "never"

[connections.production-judge]
retry = "never"

Every verdict on that connection reads no, with a note saying the policy is why. The receipt reports the stance before the run rather than leaving you to infer it afterwards. It does not make modelpass retry the other connections — nothing makes modelpass retry — it is how you tell your loop that this one is off limits.

A connection can say how big its input window is

[connections.production-judge]
maxInputTokens = 200000

Read back as connection.max_input_tokens. It is your statement of the model's usable input window, in tokens, for the tool that needs to decide whether a payload will fit before it spends a call finding out.

Nothing in modelpass reads it. It bounds nothing, truncates nothing and refuses nothing — it is a stance, like retry = "never", not a mechanism.

Omitting it means unknown, and unknown is the normal case. There is no table of model names to window sizes in this library: a vendor changes a window without changing the model string, so a built-in number would go stale silently while reading as authoritative, and a wrong window is worse than none. Unknown and zero are different facts, so maxInputTokens = 0 is refused rather than stored, and anything that is not a positive whole number is refused with it. Read it as is None, never as falsy.

modelpass list --verbose prints it, and prints unknown when nothing is set.

A connection can ask for prompt caching, and be told what that bought

[connections.production-judge]
promptCache = "default"   # or a lifetime this runtime accepts, e.g. "1h"

Read back as connection.prompt_cache. It is the standing half of prompt caching. The per-call half already exists and is unchanged: CacheControl on a TextBlock is how you say where the cacheable prefix ends, which only a caller holding one payload can say.

Asking has three possible answers, and they are not the same answer.

  • Honoured explicitly — the runtime takes an instruction, so your cache_control breakpoints reach the vendor. anthropic-api today.
  • Already satisfied — the runtime caches without being asked and gives you no way to stop it, which is true of most of them. This is not an error. Nothing is sent for it and nothing needs to be.
  • Refused — modelpass has not established that this runtime caches prompts, so there is nothing to honour and nothing already happening. You get an InvalidConnection when the connection is built, rather than a setting that quietly does nothing.

You tell the first two apart on the receipt: receipt.prompt_cache_disposition reads "explicit" or "automatic", with one explaining sentence in receipt.notes. modelpass list --verbose prints the same thing.

Omitting the key states nothing, and that is not "caching off." Most of these runtimes cache whether or not anyone asks; a connection with no promptCache has simply not said anything about it.

The lifetime is the vendor's word, not modelpass's. "default" means the vendor's own, and is distinguishable from every explicit value. An explicit one is checked against what that runtime accepts, read off the SDK installed here — anthropic types '5m'/'1h', openai types 'in-memory'/'24h'. There is no house vocabulary and no translation between them, because nobody published one. A lifetime a runtime does not take is refused when the connection is built, not by a 400 halfway through a run.

What is safe to share between threads

  • One Bridge is safe to share across threads for chat, ask, preflight and validate. Each call builds its own request, its own guard tracker and its own stream, and shares nothing mutable, so N workers on one bridge get N independent answers, receipts and usage figures. The connection store and the run log are guarded by their own locks. Adapters may be shared.
  • A Session takes one caller at a time. It holds a conversation, a token tracker for the whole conversation, and a vendor handle whose turns are ordered. A second caller arriving mid-turn gets SessionBusy — raised, not queued, because waiting would produce interleaved turns in an order nobody chose, and a lock that queues can deadlock when one of the two callers is the thread draining the other's iterator. Give each thread its own session (new_chat() is local work) or take your own lock around the turn.
  • One event loop may run many achat calls concurrently. The same sharing rule, on the other face: the calls are independent, and the one blocking step in a call -- the agent runtimes' subprocess preflight -- is handed to a thread rather than run on the loop.

Async

async for event in bridge.achat(connection="claude-api", message="..."):
    ...

answer = await bridge.aask("claude-api", "Score this: ...", schema=SCHEMA)

async for event in session.asend("and then?"):
    ...

achat, aask and asend take every argument their sync twins take and answer with the same events, receipts, guards and ledger lines: one pre-run pipeline and one event fold underneath, two doors on top. chat, ask and send are unchanged and are still the whole implementation for a sync caller.

Cancelling is aclose(), where the sync contract is closing the iterator:

events = bridge.achat(connection="claude-api", message="...")
try:
    async for event in events:
        ...
finally:
    await events.aclose()

That cancels the run through the adapter's own cancel() and joins the worker thread where one was used. On the four API runtimes there is no worker: their adapters drive the vendor's async client on your loop, and an async def tool handler is awaited there too -- no thread hop, no run_coroutine_threadsafe. Added 2026-09-13 (ticket 1.13).

Sessions, where the runtime holds the conversation

chat() is stateless: whatever history= you pass is re-sent every call, which is a known cost because you can see it. A session is the other door — the runtime holds the conversation, the prefix cache is native to it, and you send one turn at a time.

with bridge.new_chat(
    connection="claude-sub",
    system_prompt=RUBRIC,
    stop_at_tokens=200_000,        # the whole session's ceiling, not the turn's
) as session:
    for item in items:
        for event in session.send(f"Score this: {item}"):
            ...
    print(session.id, session.usage.total_tokens)

Four doors: new_chat(), new_worker(project_folder=...), resume_chat(session_id=...) and list_sessions(). A ChatSession replaces the runtime's own agent persona with your system_prompt and switches its native toolbelt off — a scorer, a classifier, a rewriter, an assistant. A WorkerSession keeps both and appends yours, and requires a project_folder, because work where the runtime's own agent pointed at a real folder is the point is what it is for.

Three properties of the shape, each deliberate:

  • There is no tools= and no system_prompt= on send(). Both are fixed at construction, because they sit at the very front of a prefix that has been warm since the first turn; re-declaring one would mean either ignoring it silently or changing that prefix silently.
  • Guards on a session are the session's envelope, not the turn's. stop_at_tokens bounds everything this session will ever spend — a per-turn tracker on a fifty-turn worker never fires while the allowance drains.
  • A session takes one caller at a time. A second arriving mid-turn gets SessionBusy rather than an interleaved conversation, raised instead of queued because a lock that queues can deadlock when one of the two callers is the thread draining the other's iterator. asend() is the async twin and contends for the same lock.

Construction runs the preflight and every capability assertion and hands back a local object with a receipt already available — but nothing is spent and no session exists yet. Neither runtime has a create-session call, so the session comes into being when the first send() completes, which is also when Session.id stops being None. A stored id is therefore a hint rather than a guarantee, and SessionNotFound from resume_chat() is an ordinary outcome rather than a broken caller: Claude Code sweeps old transcripts on its own schedule.

Sessions exist on the two agent runtimes only. The four API runtimes hold no conversation — every request carries its whole history — so all four doors are shut there, at the bridge, before an adapter is loaded or a credential is resolved:

runtime 'anthropic-api' capability 'sessions_resume' is unsupported:
bridge.new_chat() needs the runtime itself to hold the conversation (D14), and
'anthropic-api' holds none: every request carries its whole history. Use
bridge.chat(history=[...]), the stateless alternative, or bridge.ask(...) when
one blocking answer is what you want

That refusal is read off the capability table rather than hard-coded per runtime — the rule is every one of sessions_resume / sessions_fork / sessions_list reading unsupported, which is what an HTTP request/response API looks like from here. It is CapabilityNotSupported, carrying .runtime, .capability and .support for a caller who would rather branch than read English. new_worker() carries a second refusal of the same class on the tools cell: a runtime with no native toolbelt would otherwise have handed back a chat wearing a worker's name.

The two models are set side by side in docs/api-and-runtimes.md.

Managing connections from Python

modelpass connect is a consent flow, and a desktop app's Settings page needs the same one without a terminal. bridge.manage is that flow as a library: the same validation, the same receipt, and the same rule that nothing is written before the receipt exists.

plan = bridge.manage.plan_connection(                  # writes nothing
    name="claude-api",
    runtime="anthropic-api",
    credential=modelpass.Credential.secret(pasted_key),  # or .env("MY_KEY"), or .native_login()
)
print(plan.receipt.summary(), plan.notes)              # show this before you save
bridge.manage.add_connection(plan)                     # secret first, then the connection
bridge.manage.remove_connection("claude-api").note     # "removed secret entry 'claude-api'"

rename_connection(old, new) moves the key with the name when it is unambiguously that connection's, set_enabled(name, False) stops runs without deleting anything, set_groups(name, ["fast"]) replaces the groups a connection is in, and every result is a dataclass with a to_dict() that is safe to print — the pasted value is in none of them, in none of their reprs, and never reaches the connection file.

The bench

pip install modelpass[bench]
modelpass bench            # http://127.0.0.1:8765

A local page — loopback only, no authentication because it is not a service — that shows the things this README claims, rather than describing them:

  • Connections — everything configured, with each one's receipt one click away, and an ambient-environment audit: which credential variables are set on this machine right now (names only, never values) and the statement that runs will scrub them. It also notes that the audit reflects the environment of that process — a terminal opened before you cleaned your environment still carries stale copies, which is precisely why modelpass scrubs at launch instead of trusting what it inherits. Each row also carries what a run turns on: the runtime and auth mode, the credential source (never a value), the base URL where there is one, the timeout bound and the retry stance, and — on openai-compatible — the capability cells a drive recorded and when, or a plain statement that nothing has driven this endpoint yet. Below the listing, the stored keys panel names each entry in the secrets file and the connections referencing it; an entry nothing references is marked an orphan and there is deliberately no button here that deletes one.
  • Vendor setup — create, edit, rename, enable/disable and delete connections, through the same manager modelpass connect uses, so the secrets file is written by one implementation rather than two. Four credential forms: the runtime's own login, a named environment variable, a key pasted once into a write-only field that is never echoed back into the page, or none at all for an endpoint that checks nothing. A pasted key in the variable name field is still refused by the validation that guards the config file. Renaming moves the stored entry with the connection.
  • Groups — every group, its members, and which one group:<name> would reach, with the reason each other member was passed over. A group whose members are all disabled is shown as one that would be refused, because in a plain listing it reads exactly like a group that works. Each account's form has a groups box, and each row's group tags filter the listing.
  • Verify — on a subscription, pin the vendor identity currently logged in; on an openai-compatible connection, drive the endpoint and record what answered. The second confirms first, because it spends.
  • Playground — a real chat with a model picker (the Codex CLI's own model list where one exists; the models a drive saw your endpoint serving where one has run; labelled aliases where neither does), streaming the entire event log live: deltas, thinking, usage, guard events, tool calls, and the stamped terminal. There is a hello-world tool you can attach to watch a caller-supplied tool round trip end to end. It spends real allowance and says so.
  • Run log — recent runs from runs.jsonl, newest first, with the three questions a ledger row could not previously answer: were the cache_control breakpoints honoured, was the run at the sampling its config says, and is this failure worth retrying — the last computed from the row's own runtime and terminal status by the same function the library gives your loop.

Command line

Every command, in one table. modelpass --help and <command> --help carry the same text, and the subpass alias invokes all of it identically until 0.3.0 — the help output names whichever of the two you actually typed.

Command What it does
modelpass connect <vendor> Run the preflight, print the receipt, and write a connection once confirmed. Vendors: anthropic, openai, google, compatible. --name NAME names it; --nickname LABEL adds the human name; --model NAME pins a model; --description TEXT leaves you a note; --config-dir PATH selects isolated vendor storage; --runtime NAME picks the vendor's runtime exactly; --base-url URL names the endpoint; --api-key-env NAME selects metered mode on a named variable; --api-key-stdin takes the key on a pipe and stores it in the secrets file; --warn-at-tokens N and --stop-at-tokens N set guards; --allow-env NAME lets one non-credential variable through the scrub; --group NAME puts it in a group (repeatable); --force overwrites an existing connection; -y writes without asking.
modelpass list One line per connection: runtime, auth mode, credential source, base URL, groups, guards, disabled state. --group NAME narrows it to one group. -v adds the timeout bound, the retry stance and the capability cells a drive recorded.
modelpass accounts The same connections read as account profiles — nickname, stable ID, guards, whether the identity is pinned, disabled state. -v adds the model, allowEnv and configDir. list is the alias-compatible legacy spelling of this view.
modelpass check [NAME] Re-run the preflight and print the receipt, for one connection or for all of them. Exits non-zero if a connection would not run. Reports the secrets file's permissions and any orphan entries, and on an openai-compatible connection what a drive found — or that nothing has driven it yet.
modelpass verify NAME Go and find out the live answer, and write it down. On a subscription: re-read the vendor's account identity, bypassing the preflight identity cache, and pin the connection to it. On an openai-compatible connection: drive the endpoint with one chat, one tool round trip and one structured-output call, and record which cells actually worked. The second form spends, and says so before it does.
modelpass groups [NAME] The groups and who is in them, with the member group:<name> would actually reach and why the others were passed over. A group with members and no enabled one is reported as one that would be refused.
modelpass set-groups NAME [GROUP...] Replace the groups a connection is in — the whole list, not one entry. Naming no group returns it to default. Says when a group has just lost its last member and no longer exists.
modelpass enable NAME Put a connection back into service.
modelpass disable NAME Take a connection out of service without deleting it: it stays listed, check still runs against it, and its guard thresholds survive. Says when this leaves a group with no enabled member.
modelpass rename OLD NEW Rename a connection, moving its secret entry with it when the entry is that connection's own and nothing else references it — and saying so when it moves only one of the two.
modelpass remove NAME Delete a connection, after printing what goes with it. Its stored key goes too when the entry was unambiguously that connection's; a key another connection references stays, and this says so. -y skips the confirmation.
modelpass secrets The entries in the secrets file, and which connections reference each. An entry nothing references is named as an orphan and is never deleted for you. No value is printed, and no flag prints one. -v adds the file's permission note.
modelpass bench Serve the local configuration and test page on 127.0.0.1:8765. --port N moves it. Needs the bench extra.

Boundary and compliance

Intended use: personal development, local tooling, and workflows running under the developer's own authenticated account — or, for a distributed tool, under its user's own account.

Explicitly not designed as:

many external users -> one developer subscription -> shared model access

That shape is prohibited by at least one vendor's terms and unwise under all three. It is also not something modelpass can be talked into: connections are local, credentials stay in the vendor's own login store, and there is no server component. For hosted or multi-user production workloads, use the vendors' official APIs and commercial terms.

Two more lines, held to throughout:

  • Never token-level. modelpass drives vendor runtimes; it never extracts, stores or replays a subscription token. See Runtime-level passthrough only.
  • Never redistributes a runtime. The vendor packages are dependencies, so proprietary bits arrive through the vendor's own channel under the vendor's own terms.

Runtime-level passthrough only — and why that is the compliant shape

There are two ways to reach a subscription from code:

  1. Token-level — extract the subscription's OAuth token and call the vendor's API with it.
  2. Runtime-level — drive the vendor's own agent runtime, as the vendor intends.

modelpass does (2) only, and will never do (1). Not as a matter of taste:

  • Anthropic restricted consumer-plan OAuth to Claude Code and claude.ai in a 2026-02 legal update, with server-side enforcement — extracted consumer tokens now error on direct API requests.
  • Token extraction is the pattern behind community-reported account blocks, and at least one vendor's terms now name a token-level tool as their worked example of a breach.
  • Runtime-level use is explicitly covered — Anthropic's Agent SDK credit exists for it.

modelpass is not that pattern. Token-extraction tools lift the subscription's OAuth token and impersonate the vendor's own app against the vendor's API — which is what the vendors have moved against, and what they now block server-side. modelpass runs the vendor's own runtime, under your own login, on your own machine, exactly as the vendor ships it — and shows you a receipt proving which account paid. One is circumvention; the other is using what your plan includes. The per-vendor legal pages below spell out each vendor's written position with links to the primary texts.

It is also the only architecture that keeps working. A token-scraping tool breaks the next time a vendor rotates a storage format; a tool that drives the vendor's own runtime breaks when the vendor breaks their own runtime, which they have every reason not to do.

The vendors' written positions, summarized

One page per vendor: what their terms say (quoted and dated), what that means for using modelpass with that vendor, what you must never do, and links to the primary texts.

Vendor Position (summarized) Details
Anthropic Individual Agent SDK use under your own plan is explicitly covered; mediating other people's credentials is prohibited docs/legal/anthropic.md
OpenAI ChatGPT-authenticated Codex use is a documented plan feature; no prohibition found on wrapping the first-party CLI, but no affirmative blessing either docs/legal/openai.md
Google Excluded. The terms name third-party tools as a breach, penalty account termination — do not wire a Google subscription through third-party tooling at all docs/legal/google.md

The disclaimer that applies to all three: those pages are summaries written by this project, not legal advice, and not the terms themselves. Vendors may update their terms at any time, without notice, and enforcement postures shift faster than documentation. The dates on each page say when the primary sources were last read; verify them yourself before relying on anything, and treat your account — and anything a vendor does to it — as your own responsibility. modelpass is provided AS IS under the MIT license, without warranty of any kind.

A note on freshness

Every provider fact in this repository is dated, and this space moves fast. Within the last six months: Google replaced its entire CLI (June 2026), Anthropic introduced the Agent SDK credit (June 2026), and OpenAI moved Codex to token-based accounting (April 2026). Auth precedence is exactly the kind of thing that changes in a point release — which is the argument for the scrub in the first place.

Re-verify before implementing against any dated claim here, including the compliance ones. The verification documents below record what was checked, when, and against what.

Migrating from subpass

The library was called subpass through 0.1.1. Everything about it is the same except the name, and 0.2.0 ships the old name as a shim so nothing has to move on your schedule.

What Before Now Old spelling works until
Import import subpass import modelpass 0.3.0, with one DeprecationWarning
Submodules from subpass.langchain_adapter import ChatSubpass from modelpass.langchain_adapter import ChatSubpass 0.3.0
Config directory ~/.subpass/ ~/.modelpass/ 0.3.0 (copied forward on first use)
Env override SUBPASS_HOME MODELPASS_HOME 0.3.0, with a DeprecationWarning
Command subpass connect anthropic modelpass connect anthropic 0.3.0 (alias entry point)

Two things deliberately did not change: the exception taxonomy is still SubpassError and its subclasses, and the LangChain chat model is still ChatSubpass with its subpass_* response_metadata keys. Renaming something a caller catches or reads by name is a contract change, and it is not part of a rename. They get their own decision later.

The shim is not a copy. subpass.Bridge is modelpass.Bridge — every name it hands out is the object modelpass defines, so isinstance checks and except clauses still work when one half of a program has migrated and the other has not.

On first use, if ~/.modelpass/ does not exist and ~/.subpass/ does, your connections are copied forward and a one-line note says so. The old directory is never deleted, so a tool still running 0.1.x keeps reading exactly what it always read.

Contributing and development

git clone https://github.com/staceyfarias/modelpass
cd modelpass
python -m venv .venv
.venv/Scripts/activate            # source .venv/bin/activate on macOS or Linux
pip install -e ".[dev]"
python -m pytest -q
ruff check src tests

The dev extra installs pytest, ruff, Flask, langchain-core, and the three vendor clients the API adapters are tested against. The two subscription runtimes need their own extras and their vendor CLI on PATH (see Install); their tests use fakes and need neither.

You do not need any of it to run the suite. pip install pytest against a bare checkout is enough, and that is exactly what CI does — nothing but the test runner, so the run is also the check that modelpass still imports with zero dependencies. The few cases that cannot be faked, because they resolve an adapter through the registry or drive a Bridge that refuses a runtime whose SDK does not import, skip there with a reason naming the extra they want; installing .[dev] runs them.

Four rules the repository holds itself to, spelled out in AGENTS.md:

  • Core keeps zero runtime dependencies. Vendor SDKs are extras, and the modules outside adapters/, bench/ and langchain_adapter.py import none of them.
  • A capability cell moves only with recorded evidence — a test and the SDK surface it was read off, each with a dated note. unverified means nobody checked; it is not a soft yes.
  • No test spends allowance. Live tests carry the live marker, are deselected by default, and need SUBPASS_LIVE_TESTS=1 plus their own per-file variable before they will run at all. Having a vendor key on the machine is never on its own enough.
  • User-visible wording, event.type strings, error class names and pip install hints are consumer contract, and do not change outside a ticket that says so.

The roadmap, the ticket plan, the accepted design, the dated decision log and the per-phase verification write-ups are not published. What is published is the contract and the evidence behind it — including the verification record, which carries what each pass checked, against which build, on what date, and the measurement each capability cell rests on — plus CHANGELOG.md for what each release actually changed.

Documentation

Document What it is
docs/api-and-runtimes.md The API by contract: every entry point's promises and refusals, the full capability matrix, the dated verification record behind it (§2.2a — what was checked, against which build, on what date), and what each runtime does with your history, prompt caching and token accounting
CHANGELOG.md What each release changed, with a Contract names list per release
docs/subscription-proof-2026-08-17.md How do I know it was the subscription? The falsification experiment, its control, and the ledger you can check without trusting modelpass
docs/legal/anthropic.md · openai.md · google.md Per-vendor legal summaries: what the terms say, what you may and must not do, primary links — not legal advice; terms change at any time
docs/guards.md Bounding what a run can spend: why there are no defaults, how to pick a number, when guards can actually fire on each runtime, where the vendor-side hard cap a metered connection needs is set, and why the unit is still tokens

License

MIT. The runtimes modelpass drives are the vendors' own packages under their own licenses and terms; modelpass never redistributes them.

Release files for modelpass 0.2.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for modelpass 0.2.2
File Size Uploaded
modelpass-0.2.2.tar.gz 1.0 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for modelpass 0.2.2
File Interpreter ABI Platform
modelpass-0.2.2-py3-none-any.whl Python 3 none any Details

Total release size: 1.6 MB

Release files / modelpass-0.2.2.tar.gz

Download URL modelpass-0.2.2.tar.gz
Size 1.0 MB
Tags Source
SHA-256 checksum
How to use checksums
a85e9db2c9f39590eef93a900a9e0f06422d3571fc45f4ccd3f7f32250de038c
BLAKE2b-256 checksum
How to use checksums
e5b1df167bc06492a4280395b46fbc2b0d17f6dc8ac40f401c7b71dea2f1bb0f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

Release files / modelpass-0.2.2-py3-none-any.whl

Download URL modelpass-0.2.2-py3-none-any.whl
Size 593.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9219d7c89bd05ea38a20717e086894636af56588d32b19aee10f866f43df1dc8
BLAKE2b-256 checksum
How to use checksums
ed34d36778edf5aa503491717edfb9553c7673d599edebf3177f197595bd3257
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.2 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page