Troubleshooting¶
Solutions for common issues when working with Axio.
Installation¶
uv: command not found¶
Install uv first:
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell)
irm https://astral.sh/uv/install.ps1 | iex
Or install Axio with pip: pip install axio.
API Keys¶
StreamError: ... 401 on the first turn¶
No transport checks for a missing key. Each one defaults api_key to the environment
variable below, or to the empty string. An empty key reaches the provider and comes back
as a 401. The transport wraps that 401 in axio.exceptions.StreamError. Set the variable
for the transport you constructed:
# Anthropic
export ANTHROPIC_API_KEY="sk-ant-..."
# OpenAI
export OPENAI_API_KEY="sk-..."
# Google (Gemini developer API; the realtime transport also accepts GOOGLE_API_KEY)
export GEMINI_API_KEY="..."
# Nebius
export NEBIUS_API_KEY="..."
# OpenRouter
export OPENROUTER_API_KEY="..."
axio-transport-codex uses OAuth rather than a key. Vertex AI uses Google Application
Default Credentials.
Invalid API key / Authentication error¶
Verify the key is correct - no extra spaces or quotes in the env var
Check the key has not expired
For OpenAI: make sure you have active billing
For Anthropic: ensure the key has the right permissions
Transport Connection¶
Connection refused / Failed to connect¶
Check your internet connection
Verify the API endpoint is correct (especially for custom endpoints)
Some corporate networks block external APIs - try using a VPN
For OpenAI-compatible APIs: verify the base URL in your transport config
Timeout error¶
The API may be slow or experiencing high load
Try again in a few moments
If persistent, increase the timeout in your transport settings
SSL certificate error¶
Update your Python version - newer versions have updated CA certificates
On macOS: run
/Applications/Python\ 3.x/Install\ Certificates.commandOn Linux: update ca-certificates:
sudo apt update && sudo apt install ca-certificates
Provider Requests¶
400: function tools and reasoning_effort cannot be combined¶
/v1/chat/completions refuses function tools beside any reasoning effort other than
"none". A reasoning model reasons by default. A request that carries tools therefore
fails with a 400 naming a parameter you never sent.
OpenAITransport.build_chat_payload heads that off. When the model has
Capability.reasoning and the request carries tools, it sets reasoning_effort: "none"
and warns. The second symptom is therefore the warning rather than the 400. You then pay
for a reasoning model that is asked not to reason.
This affects the transports that speak that endpoint. OpenAICompatibleTransport,
NebiusTransport and OpenRouterTransport all set api="chat". OpenAITransport
defaults to api="responses". /v1/responses takes both together.
On OpenAI itself, leave
apiat its default.On a compatible server that implements
/v1/responses, passapi="responses".On one that does not, decide the trade explicitly. Either pass
extra_params={"reasoning_effort": "low"}- which suppresses the override - and drop the tools, or keep the tools and accept"none".
400 from Anthropic on a replayed thinking block¶
With extended thinking on, a turn that thought and then called a tool is refused unless
its thinking comes back with the signature the API issued for it. The transport replays a
ReasoningBlock only when signature is set, or when redacted is. An unsigned block
is dropped rather than sent, because there is nothing to prove it is the model’s.
The failure lands one turn late. The request that fails is the one after the turn whose
signature was lost. Suspect a context store that round-trips blocks through
to_dict/from_dict and drops the field. Never inspect, decode, re-encode or truncate a
signature. It is opaque, and a changed one is as bad as a missing one.
MISSING_THOUGHT_SIGNATURE from Gemini¶
This is the same failure on Google’s side. Google publishes it as a
finishReason. The transport maps it to StopReason.error, which ends the run.
Three replay paths, easy to confuse:
A thought that had text goes back as a part with
thought: trueand itsthoughtSignature.A signature that arrived on a function-call part goes back on that part, not on a thought part. Gemini puts the proof on the part it signed. Sent as a text-less thought part, the call it belongs to comes back
MISSING_THOUGHT_SIGNATURE.A signature that arrived on a plain answer-text part is stored on that
TextBlockand goes back on the text part. Held as reasoning it made a text-less thought part, whose proof the next unsigned call then took.
Parallel calls consume unplaced signatures in arrival order, so the store has to preserve the order of blocks within the assistant turn.
A turn that reads as an empty answer¶
run() returning "" with no exception, on a prompt the model declined. The decline
arrives as a Refusal event, not as TextDelta. A renderer or a get_final_text() that
only collects text prints nothing.
AgentStream.get_final_text() - and so Agent.run() - collects Refusal.text, and
every transport now sends some. Gemini generates none of its own for a decline, so its
transport writes the text and marks it spoken=False: a blocked prompt carries
blocked_input=True, and a candidate that finished on SAFETY, RECITATION or the rest
carries the finish reason as its category.
The stop reason is still the thing to branch on. The text is for a reader. A refusal is terminal and deliberately not an error. Reported as one, it leaves a caller unable to tell a decline from a broken connection. The caller then retries something that can never work.
import asyncio
from axio import Agent, MemoryContextStore, Refusal, StopReason, Usage
from axio.events import IterationEnd, SessionEndEvent
from axio.testing import StubTransport
transport = StubTransport([[
Refusal(index=0, category="safety", blocked_input=True),
IterationEnd(0, StopReason.refusal, Usage(10, 0)),
]])
agent = Agent(system="You are helpful.", transport=transport)
async def diagnose() -> tuple[str, StopReason]:
text: list[str] = []
stop = StopReason.error
stream = agent.run_stream("...", MemoryContextStore())
async for event in stream:
match event:
case Refusal(text=refused):
text.append(refused)
case SessionEndEvent(stop_reason=stop_reason):
stop = stop_reason
return "".join(text), stop
answer, stop = asyncio.run(diagnose())
assert answer == ""
assert stop is StopReason.refusal
A long turn that stops mid-answer¶
A reasoning event large enough to blow past aiohttp’s 131072-byte line limit. Reading
the stream by lines raises LineTooLong. LineTooLong is not a ClientError, so it
escapes the retry paths. The turn ends with no answer.
axio-sse exists for this. It takes chunks cut anywhere and never lines, so no line
length is a limit. Every transport in this repository reads through it, from
resp.content.iter_any(). A transport of your own that reads the response line by line
has the bug. Feed axio_sse.payloads() or Reader.over() the chunks instead.
The mirror-image mistake is a chunk iterator that strips line terminators - httpx’s
aiter_lines(), for one. The decoder needs them to know an event ended. Nothing
therefore dispatches at all, and the turn is silent rather than truncated.
Tools¶
Tool not found¶
Tools must be passed explicitly to the Agent:
from my_tool import my_tool
agent = Agent(
system="You are helpful.",
tools=[my_tool], # explicitly pass
transport=transport,
)
Tool execution failed¶
Check the error message:
Timeout: the tool took too long - consider async optimization
Permission denied: a guard blocked the tool - see “Permission guards” below
Import error: check the tool handler’s dependencies are installed
Tool returned empty result¶
Verify the tool logic is correct
Check logs for exceptions during execution
Add debug output in your tool handler to see what’s happening
Permission Guards¶
Permission denied for every tool call¶
Guards are blocking all tool calls. Check:
Check which guards are attached to your agent in your configuration
For
PathGuard: it asks before a tool touches a path. A yes allows the parent directory, and a no remembers the path. One denial therefore keeps denying for the rest of the session. Its default prompt callsinput(). Pass aprompt_fnwhere there is no terminal to read from, or every call blocksFor
LLMGuard: it puts the decision to an agent, so that agent’s transport needs a working API key of its own
Both ship in axio-tui-guards, and are registered under the axio.guards entry-point
group. See Packages.
Context & Storage¶
Database is locked (SQLite)¶
Multiple processes are accessing the same SQLite database. Solutions:
Use WAL mode (enabled by default in Axio)
Ensure you’re using a single process
Increase busy timeout in connection string
Session not found¶
Check the
session_idis correctFor SQLite: verify the database file exists and has data
The session may have been deleted or expired
Development¶
Module not found when importing axio¶
Ensure you’re in the right environment:
uv run --directory axio python -c "import axio; print(axio.__file__)"
uv run --directory <package> is how every command in this workspace is run. There is no
uv shell. To get an interactive environment instead, create one with uv venv and
activate it.
Type checking errors¶
Axio uses strict typing. Every package sets strict = true. Each is checked from its own
directory. mypy axio/ from the repository root points at the distribution directory
rather than at the sources under axio/src/axio:
make typing # every package
uv run --directory axio mypy . # one package
Tests failing¶
Run tests with verbose output:
uv run --directory axio pytest -v
Every package sets testpaths = ["tests", "README.md"], so its README’s Python blocks are
executed too. The documentation has its own suite: make test-docs.
Check if the failure is in your code or the framework:
If in framework: open an issue on GitHub
If in your code: verify against the test examples in
docs/andaxio/tests/
Getting Help¶
If your issue isn’t listed here:
Search GitHub issues
Open a new issue with:
Python version
Axio version (
python -c "import importlib.metadata; print(importlib.metadata.version('axio'))")Full error traceback
Minimal reproduction code