ConduitSharp.TokenFlow
1.0.0
See the version list below for details.
dotnet tool install --global ConduitSharp.TokenFlow --version 1.0.0
dotnet new tool-manifest
dotnet tool install --local ConduitSharp.TokenFlow --version 1.0.0
#tool dotnet:?package=ConduitSharp.TokenFlow&version=1.0.0
nuke :add-package ConduitSharp.TokenFlow --version 1.0.0
TokenFlow
Track token spend across any AI agent, ships pre-configured with Anthropic and Codex and Local LLM routes.
Run
One command, no clone. Pick whichever runtime you already have.
# Node, no .NET needed. First run fetches the .NET 10 runtime (~46 MB) into ~/.tokenflow
npx @liqngliz/tokenflow
# .NET 10 SDK
dotnet tool install -g ConduitSharp.TokenFlow && tokenflow
dnx ConduitSharp.TokenFlow # no install, .NET 10 SDK only
# Docker
docker run -d --restart unless-stopped --name tokenflow \
-p 5050:5050 -v "$PWD/logs:/data" \
ghcr.io/liqngliz/tokenflow
Serves the dashboard on http://localhost:5050. GET /info prints the setup lines below.
| spend rows + wire log | override | |
|---|---|---|
| tool | ~/.conduit-spend/ |
CONDUIT_SPEND_DATA |
| image | ./logs (the mount) |
remount /data |
Port: --urls http://localhost:PORT for the tool, -p PORT:5050 for the image.
On macOS and Windows a local model server on the host is reachable out of the box, on its default loopback binding, with no extra flags.
# Follow the gateway's output. Startup errors, plugin registration and route
# validation failures all show up here.
docker logs -f tokenflow
# Stop and delete it.
docker rm -f tokenflow
On Linux Add the host mapping, which Docker Desktop provides automatically and Linux does not:
--add-host=host.docker.internal:host-gateway
and bind your model server to 0.0.0.0 rather than 127.0.0.1, because a loopback-only service
refuses connections from a container. Docker 20.10+ is required for host-gateway.
Your own routes
Three routes ship in the image:
| route | for | forwards to |
|---|---|---|
/llm/claude |
Claude Code | api.anthropic.com |
/llm/codex |
Codex | chatgpt.com |
/llm/local |
LM Studio, Ollama, anything OpenAI-compatible | host.docker.internal:1234 |
To run different ones, pull the shipped config out, edit it, and mount it back:
docker run --rm --entrypoint cat ghcr.io/liqngliz/tokenflow \
Configuration/routes.json > routes.json
Then add -v "$PWD/routes.json:/app/Configuration/routes.json:ro" to your docker run.
Inside a container 127.0.0.1 is the container, so a service on your machine is
host.docker.internal.
Point each tool at it
Claude Code
File: ~/.claude/settings.json
{
"env": {
"ANTHROPIC_BASE_URL": "http://localhost:5050/llm/claude"
}
}
Restart Claude Code after editing. A .claude/settings.json inside a repo overrides the one in your
home directory, which is how you give a single project its own route.
Anthropic gzips its SSE stream, so token-spend decompresses before reading usage. Anthropic also
splits the columns across two frames, input and cache in message_start and output in
message_delta, which is why the plugin keeps the largest total per column rather than the last.
Codex (VS Code extension)
File: ~/.codex/config.toml
model_provider = "conduit"
[model_providers.conduit]
name = "ConduitSharp"
base_url = "http://localhost:5050/llm/codex/backend-api/codex"
wire_api = "responses"
Restart the extension after editing.
No env_key. With it omitted, resolve_provider_auth falls through to whatever Codex is
already signed in with, so a ChatGPT-plan session works with no API key. With it set, Codex reads
that environment variable instead.
LM Studio
OPENAI_BASE_URL=http://localhost:5050/llm/local/v1
The local route points at host.docker.internal:1234, LM Studio's default port on the host. For
Ollama or another local server, change that destination in your own routes.json (see above).
What you get
Spend rows, one JSON object per request, one file per UTC day:
cat logs/spend-$(date -u +%F).jsonl
{"ts":"2026-08-01T21:31:41Z","route":"local","model":"qwen-2.5","caller":"6200a49014e7…",
"in":40,"out":110,"cacheWrite":0,"cacheRead":0,"think":0,"session":"9916b6fc15f66f36",
"turn":3,"tools":0,"ms":24,"streamed":false,"prompt":"second ask"}
think is reasoning tokens, billed inside out on the wire (the dashboard splits them into separate Out and Think metrics). Path per route via
thinkingFields: usage.output_tokens_details.thinking_tokens (claude),
usage.output_tokens_details.reasoning_tokens (codex),
usage.completion_tokens_details.reasoning_tokens (local, OpenAI Chat Completions shape).
session is the client's own conversation id where it sends one, read from sessionField:
metadata.user_id.session_id for Claude Code, client_metadata.thread_id for Codex. A route without
it, like local, falls back to a hash of the conversation's first user message, which splits a chat
whenever the client compacts and merges chats that open with the same synthetic preamble.
Wire log, the actual bodies both ways, at logs/conduit-wire.jsonl. This is the one to read
when you want to know what a provider really sends rather than what its docs claim.
Adding a project
One route per project, so each can carry its own budget, provider, and capture settings. Copy a
block in your routes.json, change id, the path, and the PathRemovePrefix, then point that
project's tool at the new prefix. Per-repo .envrc under direnv makes the base URL set itself when
you cd in.
Smoke test
./smoke.sh # builds tokenflow:smoke if missing
IMAGE=tokenflow:nc ./smoke.sh # reuse an image
PORT=5091 MOCK_PORT=5092 ./smoke.sh
Runs the image on port 5091 against a stub upstream on 5092, so no Anthropic or ChatGPT credential
is used and port 5050 is left alone. Data dir is a mktemp -d, removed on exit along with the
container.
| check | asserts |
|---|---|
/info |
200, names /llm/claude and /llm/codex |
/ |
<title>TokenFlow</title>, /assets/*.js 200 and > 10 KB |
| claude call | POST /llm/claude/v1/messages 200 |
| codex call | POST /llm/codex/backend-api/codex/responses 200 |
| logs | conduit-wire.jsonl and spend-<today>.jsonl non-empty, container log free of error/fail |
| live rows | claude in=1200 cw=56 cr=7800 out=340; codex in=500 cw=90 cr=1600 out=410 |
| synthetic rows | 4 rows written to spend-<yesterday>.jsonl, model test-model, both routes, returned by /api/spend/<day> with totals in=5600 cw=550 cr=3900 out=2390 |
Codex in is 500 not 2100 because subtractInputFields takes cached_tokens off the input count.
The two files are different days so the synthetic writes never interleave with the live writer's
background channel.
Routes come from Configuration/routes.json with only the two upstream addresses sed-swapped, so
a plugin-config change is covered without editing the script. Exit code = number of failed checks.
Not covered: SSE (/api/spend/stream), the local route, and DOM rendering. The dashboard is
checked at its data path (/api/spend, /api/spend/{date}) and by the bundle being served, not by
a headless browser.
Known limits
Codex polls /models every three minutes with an empty GET, so most rows in the spend file
have turn: 0 and no tokens. Filter on turn > 0 for real traffic.
api.openai.com is not usable on a ChatGPT plan. It authenticates the OAuth token, then refuses
with Missing scopes: api.responses.write. The route here points at the ChatGPT backend instead,
which is what a plan entitles you to. An API-key user would want a second route at
https://api.openai.com with /v1 in the base URL.
The wire log holds prompt text in the clear. It captures bodies, so it contains whatever you
typed. It does not capture headers, so API keys stay out of it, but treat the file as sensitive and
delete it when you are done. token-spend itself stores only a salted hash of the caller and a
bounded prompt prefix, and only because capturePrompts is on in this example.
Two capture buffers per request. Both plugins declare their footprint, so the gateway reserves
token-spend + body-capture-file against MaxRamBufferedBodyBytes and sheds with a 503 at the
ceiling rather than growing unchecked. This example raises that budget to 128 MiB to leave room.
codex and claude have been exercised against live traffic. local is configured but
unverified.
The wire log mangles compressed responses. body-capture-file stores bodies as text, so a gzip
response comes back with its invalid byte sequences replaced and cannot be decompressed. Anthropic
compresses, so claude response bodies are unreadable there. Requests are unaffected, as are codex
and local responses, which arrive uncompressed. Spend rows are unaffected either way: token-spend
buffers and decodes its own copy.
Turning capture off
body-capture-file is here to inspect wire formats, not to run continuously. Once you have what you
need, drop its plugin block from each route in your routes.json and keep only token-spend.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.