Providers
get_provider(name) resolves a name to a provider with its default options;
provider_names() lists what is available.
from agentshim import CliAgent, get_provider, provider_names
print(provider_names())
# ['claude', 'codex', 'copilot', 'gemini', 'opencode']
agent = CliAgent("claude")
The default catalog contains the five shipped providers. To use an external
provider by name, extend it with that package's ProviderPlugin and pass the
result as catalog= to CliAgent, Agent, or probe_provider. The same
catalog works with the consumer test helpers. See Adding a Provider
for the plugin declaration and an out-of-package example.
model is an opaque provider-specific string. agentshim passes it to the CLI
unchanged and never validates it, so "sonnet" for Claude Code and
"anthropic/claude-sonnet-4-5" for opencode are both just strings. None
leaves the CLI's own default.
Pass a provider instance instead of a name when you need a provider-specific option:
from agentshim import ClaudeProvider, CliAgent, SandboxConfig, interactive_env
provider = ClaudeProvider(sandbox=SandboxConfig(allowed_domains=["github.com"]))
agent = CliAgent(provider, env={**interactive_env(), **provider.sandbox_env})
Sandboxes
The two sandboxing providers confine different things, so each has its own option rather than a shared abstraction.
Codex sandboxes every command the model runs at the OS level. By default
agentshim bypasses it (--dangerously-bypass-approvals-and-sandbox), for a
caller that already isolates the whole process. CodexSandboxConfig keeps it
on:
from agentshim import CliAgent, CodexProvider, CodexSandboxConfig
# Read the repository, write nothing, no network.
reviewer = CliAgent(CodexProvider(sandbox=CodexSandboxConfig(mode="read-only")))
# Write the workspace and one cache directory; reach a local Docker socket.
builder = CliAgent(
CodexProvider(
sandbox=CodexSandboxConfig(
mode="workspace-write",
writable_roots=["/home/me/.cache/go-build"],
network_access=True,
writable_tmp=False,
)
)
)
| mode | workspace | writable_roots |
/tmp, $TMPDIR |
network |
|---|---|---|---|---|
read-only |
read | n/a | read | no |
workspace-write |
write | write | write unless writable_tmp=False |
only with network_access=True |
danger-full-access |
write | n/a | write | yes |
Every setting is passed as a --config override, which codex exec resume
accepts where it rejects --sandbox, so a resumed turn keeps the sandbox.
workspace-write renders every key even at its default, so the user's
~/.codex/config.toml cannot widen it, and every sandboxed turn pins
approval_policy="never" so a refused command fails instead of waiting for
an approval no one will give. The config is validated on construction:
writable_roots must be absolute, and options that only apply to
workspace-write are rejected on the other modes rather than ignored.
parse_sandbox(argv) in agentshim.providers.codex recovers the config a
turn ran with, for tests.
A sandboxed turn also passes --ignore-rules. Codex runs any command that an
exec-policy prefix_rule(..., decision="allow") matches outside its sandbox,
and without the flag it would load such rules from the user's
~/.codex/rules/ (where approving a command in the TUI saves them) and from
trusted projects.
Exempting one command
Some commands need access the sandbox withholds from everything else, such
as a gateway that talks to the Docker socket. excluded_commands is the
Codex counterpart of Claude Code's excludedCommands:
from pathlib import Path
from agentshim import CliAgent, CodexProvider, CodexSandboxConfig, interactive_env
from agentshim.providers.codex import install_rules
sandbox = CodexSandboxConfig(
mode="workspace-write",
excluded_commands=["sdo detector check"],
)
home = Path("/var/lib/myapp/codex-home") # dedicated; not in the workspace or /tmp
install_rules(home, sandbox) # writes home/rules/agentshim.rules
# Codex also needs credentials in that home: copy auth.json (see
# PROFILE.auth_files) or pass OPENAI_API_KEY.
agent = CliAgent(
CodexProvider(sandbox=sandbox),
env={**interactive_env(), "CODEX_HOME": str(home)},
)
Each entry is shell words. A command the model runs is exempt when its words
start with an entry's words: sdo detector check --all is exempt,
sdo detector and /usr/bin/sdo detector check are not. Codex matches the
command line the model wrote, and anything else on that line (&&, |,
;, $(...)) keeps the whole line in the sandbox, exempt part included.
Every other command stays confined exactly as the rest of the config says.
Exemptions apply to read-only and workspace-write; danger-full-access
rejects them.
How it works, and what it costs:
- Codex has no per-command sandbox setting and no
--configkey for rules: exemptions exist only as exec-policyallowrules in.rulesfiles, and anallowrule runs the command with no sandbox at all. An exempt command can write anywhere and reach any socket or host the user can, and the model chooses its remaining arguments. Exempt only commands that are safe with any arguments. Codex offers no narrower mechanism: network access (network_access) and write access (writable_roots) apply to every command in the session. - The rules come from
$CODEX_HOME/rules/, so the turn needs a dedicated Codex home.install_ruleswritesrules/agentshim.rulesatomically and refuses a home whoserules/holds any other.rulesfile. Every Codex process that uses the home and does not pass--ignore-rulesapplies the exemption, and resumed turns must use the same home, since it also holds the session rollouts. - The provider refuses the turn (
ProviderCapabilityError) unlessCODEX_HOMEis set, absolute, and outside every directory the sandbox lets commands write: the turn'scwd,writable_roots, and/tmpand$TMPDIRwhenwritable_tmpis on. A command able to write the home could add a rule exempting itself. - Keep the home out of the system temp dir even with
writable_tmp=False: Codex refuses to create its sandbox helper under it, and then every sandboxed command fails to start. - Trusted projects'
.codex/rules/still load. A fresh home trusts no project; do not mark the workspace trusted in itsconfig.toml.
Denied commands in the event stream
Codex 0.157 does not always report a command its sandbox refused. When the
sandbox denies a write or a socket, codex exec --json often emits no
command_execution item for it, so no ToolCall or ToolResult event
reaches agentshim; a command that merely fails for another reason is
reported. Nothing else carries the denial in structured form: stderr is
silent, and the session rollout under $CODEX_HOME/sessions/ holds only the
model's own tool call (for current models, JavaScript source passed to a
code-mode exec tool) and its free-text output, with no command record and
no denial flag. agentshim therefore cannot surface these attempts. Do not
treat the absence of a ToolCall as proof that a command was not attempted;
to audit, check the effect (the file or socket the command would have
touched), as the e2e suite does.
Claude Code sandboxes only the Bash tool's subprocesses; see
SandboxConfig above.
Claude Code hooks
A policy the sandbox cannot express, such as refusing one executable in
Bash, goes in a hook. ClaudeProvider(hooks=...) adds them to the settings
agentshim already passes with --settings, after its own read-confinement
hook, so neither replaces the other. Passing a second --settings through
extra_args would compete with agentshim's.
import sys
from agentshim import ClaudeHook, ClaudeProvider, CliAgent
deny_go = ClaudeHook(
event="PreToolUse",
matcher="Bash",
command=[sys.executable, "/opt/hooks/deny_executables.py", "go"],
timeout_s=10,
)
agent = CliAgent(ClaudeProvider(hooks=[deny_go]))
command is an argv, not a shell string. Claude Code runs hooks through a
shell, and agentshim quotes each element with shlex.join, so every
argument arrives literally. Use an absolute executable: the agent's PATH is
not yours. Invalid hooks (a non-PascalCase event, an empty or bare-string
command, a NUL byte, a non-positive timeout) raise on construction. Hooks
apply to every turn, including resumed ones.
Claude Code tool policy
By default a Claude Code turn runs with --dangerously-skip-permissions: every
built-in tool is present and every call runs. That suits an agent bounded by an
outside Confinement. To have the CLI itself withhold tools, for a model that
should only answer, or only call one MCP server, pass a ClaudeToolPolicy:
from agentshim import ClaudeProvider, ClaudeToolPolicy, CliAgent, McpScope
# No built-in tools; only the tools of the session's MCP server "rs" run.
agent = CliAgent(
ClaudeProvider(tools=ClaudeToolPolicy.only(["mcp__rs"])),
model="claude-opus-5-5",
)
session = agent.start_session(cwd="/tmp/scratch", mcp_scope=McpScope.SESSION)
# Some built-ins, and a scoped allow rule.
reader = ClaudeToolPolicy(builtin=["Read", "Grep", "Bash"], allowed=["Read", "Grep", "Bash(git log:*)"])
The turn then carries --tools (the built-in set; builtin=() renders
--tools "", None keeps the CLI default), --allowedTools with one argument
per rule, and --permission-mode dontAsk in place of
--dangerously-skip-permissions. A call no rule allows is denied without a
prompt, and the model sees the refusal. A tool missing from builtin cannot be
called at all, whatever allowed says. A turn with an OutputSchema also
allows StructuredOutput, the tool Claude Code returns the answer through.
Pair the policy with McpScope.SESSION so the only MCP servers present are the
turn's own. Entries the CLI would misread (a bare string instead of a list, a
comma or space in a built-in name, a leading -, a NUL byte) raise on
construction. The policy applies to the print-mode provider; the stream
transport (ClaudeStreamTransport) takes its permissions from NativePermissions.
Capabilities
Everything a caller needs to know before running a turn is declared on
agent.profile, so nothing has to probe a provider object:
profile = agent.profile
profile.supports_resume # bool
profile.supports_reasoning_effort # bool
profile.mcp # McpMechanism
profile.output_schema # OutputSchemaStyle
profile.schema_dialect # SchemaDialect | None
profile.state_dirs # home-relative provider state
profile.darwin_state_dirs # extra state dirs on macOS only
profile.auth_env_vars # credential variables to forward
profile.skill_dirs # workspace-relative skill discovery dirs
profile.skill_discovery # SkillSignal: does the stream list offered skills?
profile.skill_invocation # SkillSignal: does the stream reveal skill loads?
profile.skill_scopes # frozenset[SkillScope] a session may request
profile.mcp_scopes # frozenset[McpScope] a session may request
profile.config_scopes # frozenset[ConfigScope] a session may request
profile.config_home_files # state-root files a ConfigScope.PROJECT home carries over
profile.supports_plugins # isolated, explicit session plugin selection
profile.container_install # shell commands installing the CLI
Asking for something a provider cannot do raises ProviderCapabilityError
before the process starts.
Per-provider behaviour
| claude | codex | gemini | opencode | copilot | |
|---|---|---|---|---|---|
| resume | --resume <id> |
exec resume <id> |
--resume <id> |
run --session <id> |
--resume <id> |
| MCP | .mcp.json |
--config mcp_servers.* |
.gemini/settings.json |
opencode.json |
--additional-mcp-config |
| output schema | --json-schema, OPEN |
--output-schema, STRICT |
none | none | none |
| reasoning effort | --effort |
--config model_reasoning_effort |
none | none | none |
| stream | stream-json |
--json |
stream-json |
run --format json |
--output-format json |
| token usage | yes | yes | yes | yes | no, see below |
| cost | yes | no | no | yes | no |
| skills offered | system/init skills (STRUCTURED) |
unknown | unknown | unknown | unknown |
| skill loads | Skill tool call, or Read of a SKILL.md (STRUCTURED) |
shell read of a SKILL.md (INFERRED) |
unknown | unknown | unknown |
SkillScope.PROJECT |
--setting-sources project,local |
features.plugins=false, user skills off in skills.config |
refused | refused | refused |
McpScope.SESSION |
--strict-mcp-config and the turn's servers as --mcp-config (inline for one-shot turns; from an inherited pipe on the stream transport, so a server's env is on no command line) |
enabled=false on configured servers, features.plugins=false, features.apps=false |
refused | refused | refused |
ConfigScope.PROJECT |
--setting-sources project,local, autoMemoryEnabled=false |
dedicated CODEX_HOME from prepare_config_home, --ignore-user-config --disable memories |
refused | refused | refused |
start_session(skill_scope=SkillScope.PROJECT) (also on run) offers the
agent only the workspace's skills (profile.skill_dirs) and the CLI's
built-in ones, not the user's personal skills or installed plugins, so who
runs agentshim does not change what the agent sees. A provider that cannot
enforce it raises ProviderCapabilityError from start_session. Side
effects beyond skills:
- Claude Code: the
usersettings source is skipped, so~/.claude/skills, user plugins (their skills, hooks and MCP servers),~/.claude/CLAUDE.mdand user settings (default model, permissions, hooks,env,apiKeyHelper) are not loaded. Credentials (.credentials.json,auth_env_vars) still work; auth configured only through user-settingsenvorapiKeyHelperdoes not, so pass it as environment instead. Workspace settings,--settings, managed policy and claude.ai account connectors still apply. - Codex: plugins are switched off as a feature, and every
SKILL.mdunder$CODEX_HOME/skills(except Codex's bundled.system) and~/.agents/skillsis disabled by path; the scan runs on the host agentshim runs on.config.toml, auth and session storage are untouched, so resume works as before.
start_session(mcp_scope=McpScope.SESSION) (also on run) connects the CLI
only to the servers in TurnRequest.mcp_servers; a turn given none sees no
MCP server. It is independent of skill_scope. A provider that cannot
enforce it raises ProviderCapabilityError from start_session.
- Claude Code:
--strict-mcp-configmakes--mcp-configthe only source, so user and project (.mcp.json) servers, plugin servers and claude.ai account connectors are all dropped. The turn's servers travel inline in argv (as Codex's do), not through the workspace.mcp.json. - Codex: every
mcp_serversentry in$CODEX_HOME/config.toml,/etc/codex/config.tomland<cwd>/.codex/config.tomlthat the turn was not given is disabled by name, and plugins and apps (the account's ChatGPT connectors) are switched off as features. The scan runs where agentshim runs. Give session servers names the user's config does not use: Codex merges same-named entries field by field.
start_session(config_scope=ConfigScope.PROJECT) (also on run) loads none
of the user's own CLI configuration: settings, hooks, global instructions,
notify commands, profiles and memory. The workspace's own instructions and
settings, the turn's flags and credentials keep working. It is independent of
the skill and MCP scopes. Verified against Claude Code 2.1.288 and Codex
0.156.1 by seeding each source with a code word or a hook that touches a file.
| Source | Claude Code | Codex |
|---|---|---|
| user hooks | --setting-sources (settings.json hooks) |
dedicated home (hooks.json) |
| global instructions | --setting-sources (~/.claude/CLAUDE.md) |
dedicated home (AGENTS.md) |
user settings, env, profiles, notify |
--setting-sources |
--ignore-user-config, dedicated home |
| memory | autoMemoryEnabled=false |
dedicated home, --disable memories |
| user skills, plugins | --setting-sources |
dedicated home |
| exec-policy rules | n/a | dedicated home |
- Claude Code isolates by flags alone and needs no home:
prepare_config_homereturns{}for it. Built-in skills stay. - Codex reads
AGENTS.md,hooks.jsonandmemories/from$CODEX_HOMEwith no flag to skip them, so the scope needs a state root of its own.prepare_config_home(profile, home, env)linksprofile.config_home_files(auth.json) from the state rootenvselects intohomeas symlinks and returns{"CODEX_HOME": home}to merge into the agent's environment. A link, not a copy: Codex rotates the OAuth refresh token on every refresh and rejects a reused one, and it savesauth.jsonby truncating and writing in place, which follows the link. Both homes therefore always hold the current login, whichever refreshed it. A sandbox around the CLI must expose the link's target read-write. The home also keeps Codex's conversations, so reuse it for every session that must resume another.build_argvrefuses the scope whenCODEX_HOMEis unset, relative, or the user's own~/.codex.--ignore-user-configalso means a workspace.codex/config.tomlis not loaded; pass settings as turn flags.
Explicit session plugins
Claude Code and Codex accept a local marketplace plugin selection through
plugins=SessionPlugins(home, refs) on Agent.session, CliAgent.start_session
and CliAgent.run. ConversationSpec.plugins carries the same option for
transports used directly. Both the one-shot and streaming transports support it.
from pathlib import Path
from agentshim import PluginRef, SessionPlugins
plugins = SessionPlugins(
home=Path("/run/session-home"),
refs=(PluginRef(marketplace=Path("/checkouts/marketplace"), name="example"),),
)
session = agent.session(cwd="/workspace", plugins=plugins)
The home must not exist on first use. The caller owns it: retain it unchanged
for resume and remove it after the session is no longer needed. Do not share
it between concurrent sessions. A completed installation record permits reuse
with the same provider and references; another selection or an unowned existing
home is refused. Paths must be absolute, and the marketplace must contain
.claude-plugin/marketplace.json. The home, checkout and credential-link
targets must be visible to the executor at the same absolute paths as on the
host. Installation commands have a 60-second timeout each, run before the first
agent process, and are not retried. An installation failure removes the new
home and raises; no agent starts with a partial selection.
The session loads exactly the named plugins plus the CLI's built-ins, excluding
the operator's plugins, skills and configuration regardless of skill/config
scope. Workspace instructions and skills still apply. Scope support remains
as declared by the transport's profile. SessionPlugins(home) selects no
plugins while keeping this isolation; omitting plugins preserves existing
scope behavior. A provider with profile.supports_plugins == False raises
ProviderCapabilityError, even for an empty selection.
- Claude uses its native marketplace-add and plugin-install commands in a
dedicated
CLAUDE_CONFIG_DIR. PROJECT setting sources exclude the user's settings and skills; inlineenabledPluginsenables the selected plugins from the new home's registry. This supports marketplace entries withstrict: falseand component lists without a sourceplugin.json, which a raw--plugin-dircannot load. Auto memory stays disabled. - Codex uses its native marketplace-add and plugin-add commands in a dedicated
CODEX_HOME. Explicit marketplace and plugin-map overrides enable the selection after scope overrides, while personal skills remain disabled.execignores user config; app-server reads only the generated config in the fresh home. Memories are disabled in both. WithMcpScope.SESSION, Codex accepts only inspectable local plugins without bundled MCP servers; other plugins are refused because enabling them could bypass that scope. - Both reuse
prepare_config_home's credential-link mechanism; personal configuration files are never copied. Environment credentials still apply. Authentication configured only through personal settings must instead be passed explicitly, as withConfigScope.PROJECT.
The prompt is never in argv on any provider: it always goes on stdin, so an
agent's own pkill -f cannot match the CLI by prompt text.
Credentials
profile.auth_env_vars names the variables a provider reads, so a caller
forwarding credentials does not hardcode provider names:
| provider | variables |
|---|---|
| claude | ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_CUSTOM_HEADERS |
| codex | CODEX_API_KEY, OPENAI_API_KEY, OPENAI_BASE_URL |
| copilot | COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN |
| gemini | GEMINI_API_KEY, GOOGLE_API_KEY |
| opencode | none: it authenticates through opencode auth |
CliAgent(env=...) replaces the environment rather than extending it. The
default is interactive_env(), which captures what a login shell would give,
because provider CLIs are usually installed by a shell rc file that a
non-interactive process never sources. Extend it explicitly:
from agentshim import CliAgent, interactive_env
agent = CliAgent("claude", env={**interactive_env(), "ANTHROPIC_API_KEY": "..."})
Choosing the credential
A CLI that finds several credentials picks one by its own precedence rules, so
setting one variable does not decide what it bills: a subscription login in its
state root, or a token left in the shell, may win. plan_auth decides instead.
It walks profile.credential_sources in the CLI's precedence order and returns
an AuthPlan holding exactly one credential the AuthPolicy allows: the
variables (plan.env) and files (plan.files) to hand the CLI. Deliver those
and nothing else of auth_env_vars or auth_files; settings files are left
out too, because they can carry credentials of their own.
import os
from pathlib import Path
from agentshim import AuthPolicy, CredentialKind, get_provider, plan_auth
policy = AuthPolicy(frozenset({CredentialKind.API_KEY}), endpoint="https://gateway.example/anthropic")
plan = plan_auth(get_provider("claude").profile, policy, env=os.environ, home=Path.home())
plan.env # {"ANTHROPIC_AUTH_TOKEN": "...", "ANTHROPIC_BASE_URL": "https://gateway.example/anthropic"}
plan.withheld # (".claude/.credentials.json", ...): names left out, never values
endpoint=None means the vendor's endpoint: a base URL in the environment is
withheld, not forwarded. A policy no present credential satisfies, an endpoint
the CLI cannot take, or a CLI whose credentials cannot be classified (opencode)
raises AuthUnavailableError, naming what is missing.
| provider | credentials, in precedence order | endpoint |
|---|---|---|
| claude | a cloud provider (CLAUDE_CODE_USE_BEDROCK, _VERTEX, _FOUNDRY); ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY (API key); apiKeyHelper in settings; CLAUDE_CODE_OAUTH_TOKEN (subscription); an Anthropic profile (ANTHROPIC_PROFILE, ANTHROPIC_FEDERATION_RULE_ID, ~/.config/anthropic); .claude/.credentials.json (subscription) |
ANTHROPIC_BASE_URL |
| codex | CODEX_API_KEY, OPENAI_API_KEY (API key); .codex/auth.json, classified by its auth_mode |
OPENAI_BASE_URL |
| gemini | an auth type selected in settings, by GOOGLE_GENAI_USE_VERTEXAI or GOOGLE_GENAI_USE_GCA, or a .gemini/.env; GEMINI_API_KEY, GOOGLE_API_KEY (API key); .gemini/oauth_creds.json (subscription) |
none |
| copilot | COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN, .copilot/config.json (subscription) |
none |
| opencode | none classified | none |
Entries in italics are ExternalCredentials: ways the CLI logs in that no
plan can hand to another copy of it. plan_auth never chooses them (and the
copy it plans for does not see them); host_auth_plan refuses a host where
the CLI would use one.
Reproducing the host's own login
A caller with no policy, that wants a confined copy of the CLI to log in the
way the CLI does on the host, asks host_auth_plan(profile, env=, home=). It
returns an AuthPlan for the CLI's own choice: the first present source of
any kind, plus the host's endpoint variables (base_url_env and
companion_env) exactly as they are set. Deliver it the same way as a
plan_auth plan, and nothing else.
from agentshim import HostAuthUnplannableError, get_provider, host_auth_plan
try:
plan = host_auth_plan(get_provider("claude").profile, env=os.environ, home=Path.home())
except HostAuthUnplannableError as error:
error.reason # HostAuthGap: why the host's login cannot be reproduced
Two choices follow the CLI's real behaviour (checked against Claude Code
2.1.300). A host base URL is reproduced, not validated: the CLI fails every
request on a value that is not a URL ("Invalid URL") and never falls back to
its vendor, so the copy fails the same way. A credential goes to the host's
endpoint whatever its kind: the CLI sends CLAUDE_CODE_OAUTH_TOKEN to a
custom ANTHROPIC_BASE_URL as a bearer token, so the plan delivers both.
(EndpointSpec.credentials, which plan_auth applies to a policy's
endpoint, does not limit a host plan.)
HostAuthUnplannableError.reason is a HostAuthGap:
| reason | the host |
|---|---|
UNCLASSIFIED |
runs a CLI that declares no credential sources (opencode) |
NO_CREDENTIAL |
has none of the declared sources |
EXTERNAL_CREDENTIAL |
would log in with an ExternalCredential |
SETTINGS_ENV |
has a settings file (ProviderProfile.settings) whose env block sets a credential or endpoint variable, which overrides the exported one |
The message names variables and files, never values. A caller that still
wants the CLI logged in elsewhere has only the host's whole auth state
(auth_env_vars and auth_files) to hand over.
The readiness probe confirms the plan from inside the environment the CLI runs
in: ProviderStatus.credential is the kind the CLI says it is using, or None
when it does not say.
Readiness probe
probe_provider(name) (or Agent.probe()) reports whether a provider could
start a turn, without running one and without calling a model. It returns a
ProviderStatus: binary_found, path, version (from --version), and
auth as an AuthState with an auth_detail that names the fix.
from agentshim import AuthState, probe_provider
status = probe_provider("codex") # local host; pass executor=/confinement= to probe elsewhere
if not status.binary_found:
raise SystemExit(status.auth_detail) # "codex was not found on PATH"
if status.auth is AuthState.FAILED:
raise SystemExit(status.auth_detail) # "codex is not logged in; run `codex login`"
status.credential is the CredentialKind the CLI reports it is logged in
with. Claude reads it from authMethod (claude.ai is a subscription,
api_key an API key; oauth_token covers both ANTHROPIC_AUTH_TOKEN and
CLAUDE_CODE_OAUTH_TOKEN, so only the environment the command ran with tells
them apart, and with both set it is None). Codex reads it from Logged in
using ChatGPT or ... an API key. The other CLIs report None.
UNKNOWN is not FAILED: it means the CLI has no cheap way to tell, or the
check could not run, and a caller must not stop on it. A missing binary is a
result, not an exception, so one pass can report every provider. The commands
run through the executor and environment a turn would use, so a confinement or
container is probed where the agent would run. Agent.probe() needs an agent
built from a provider name (constructing it already requires the binary).
| provider | version | authentication mechanism | states |
|---|---|---|---|
| claude | claude --version |
claude auth status (JSON loggedIn; also honours ANTHROPIC_API_KEY and OAuth-token variables) |
KNOWN_OK, FAILED, UNKNOWN for unrecognised output |
| codex | codex --version |
codex login status (Logged in ... / Not logged in); with CODEX_API_KEY or OPENAI_API_KEY set, "not logged in" is UNKNOWN because the command ignores them |
KNOWN_OK, FAILED, UNKNOWN |
| gemini | gemini --version |
none | always UNKNOWN |
| copilot | copilot --version |
none (copilot login is interactive only) |
always UNKNOWN |
| opencode | opencode --version |
none that proves a usable model (opencode auth list shows stored keys only) |
always UNKNOWN |
Signing in
ProviderProfile.login is a LoginSpec for the CLIs that have a sign-in
command, or None for Gemini CLI, which signs in on its first interactive run.
login.command(profile.binary) is the command line that starts the CLI's own
subscription login; device=True gives the device-code variant (Codex's
--device-auth) and raises ValueError for a CLI that has none. needs_terminal
is true when the command prompts the person, so it must run attached to a
terminal. agentshim does not run it: spawning the process, and whether to, belong
to the caller. Whether the CLI is signed in is the readiness probe above.
| provider | login command | device variant | needs a terminal |
|---|---|---|---|
| claude | claude auth login |
none | yes |
| codex | codex login |
codex login --device-auth |
no |
| copilot | copilot login |
none | no |
| opencode | opencode auth login |
none | yes |
| gemini | none | none | n/a |
agentshim.testing.probe_executor(provider, version=..., auth=..., installed=...)
is a FakeExecutor that answers these commands the way each CLI does.
Token usage
Every provider normalizes its counts into TokenUsage; see
Usage and Pricing for the breakdown and how each CLI's fields
map onto it.
Codex exec JSON reports cumulative thread totals. On 0.144.4 and 0.160.0,
turn.completed.usage copies input_tokens, cached_input_tokens,
cache_write_input_tokens, output_tokens and reasoning_output_tokens from
the thread's total, including on codex exec resume. There is no
per-invocation usage field in this stream. See the
Codex JSON event processor.
agentshim subtracts the previous raw thread report before normalizing these
counts. TurnResult.usage.tokens and UsageReport contain the invocation's
increment; ProviderUsage.raw and the completion lifecycle detail retain the
CLI's cumulative values. A fresh conversation starts from zero. Sessions
retain baselines by conversation id across forget() and adopt().
When adopting a thread from outside this session, pass its last report:
session = agent.start_session(session_id=saved_id, previous_usage=saved_usage)
# Or: session.adopt(saved_id, previous_usage=saved_usage)
Checkpoint saved_usage.provider and saved_usage.raw along with the id.
ProviderUsage.to_dict() omits raw, so its output alone is insufficient.
Reconstruct a checkpoint with ProviderUsage(provider="codex", raw=saved_raw).
If a resumed invocation has no baseline, it returns its result normally with
ProviderUsage.increment_known=False on both TurnResult.usage and
UsageReport. Token counts are zero placeholders, while tokens.turns still
counts completion frames. Check increment_known before pricing or budgeting;
these placeholders do not mean the invocation was free. The cumulative total
remains in raw, never in the increment. The session retains that total as the
next invocation's baseline, so subsequent increments are known. The missing
increment cannot be recovered from that frame alone. CLI failures retain their
original classification. Changes to the same
thread outside the session must be accompanied by an updated baseline.
Missing reports cannot account for tokens consumed before a failed invocation
ends; those tokens appear in the next observed total. Inconsistent or decreasing
counts are clamped by normalized_usage after subtraction.
One other gap:
Copilot CLI reports no token counts. Verified on 1.0.83, a run prints no
assistant.usage frame and no per-message outputTokens, so
TurnResult.usage.tokens is all zeros. Its session.usage_checkpoint frame
carries premium-request and AI-unit billing counters plus prompt-cache
diagnostics, none of which is the turn's billed token usage, so the parser
does not read them into TokenUsage.
All five providers ship on the same protocol.