Skip to content

Repository files navigation

orchat

A single-file .NET console client for OpenRouter. Hold a proper multi-turn conversation, switch between any model in the live catalogue, and see what each turn cost. One file, no project, no packages.

> what is the difference between a record and a class in C#?
Records are reference types with value-based equality...
[26 in / 180 out, $0.002784, 2.1s, session $0.0028]

> now show me when that equality actually bites

I wrote about building this in Building orchat, a single file .NET client for OpenRouter.

Why it exists

OpenRouter emailed to say my credits were about to expire. Credits older than twelve months expire if the account sees no inference activity, and running any request resets that clock.

Proving it worked was the hard part. The dashboard rounds every figure to two decimal places, while a request big enough to keep an account alive costs about $0.0002. Every screen said $0.00, before and after. So I wrote a client that reads the numbers the dashboard rounds away. See Proving the credit timer reset.

It turned out more useful than the problem that prompted it. Two things landed at once: .NET 10 runs a loose .cs file with no project around it, and OpenRouter puts every model worth trying behind one API and one key. Put those together and a usable chat client with model browsing and cost reporting is about 300 lines in a single file.

Requirements

  • .NET SDK 10 or later. Check with dotnet --info. File-based apps need 10.
  • An OpenRouter API key.

No project file, no NuGet packages, no build step. Everything used is in the shared framework.

Storing the API key

Do not put the key in a .env file, in ~/.zshenv, or in dotnet user-secrets. None of those encrypt anything, and the first two land in a repo sooner or later. On macOS the keychain is already there, already encrypted, and needs no code.

Store it once. The command prompts for the value twice and echoes nothing, so the key never reaches your shell history or your terminal scrollback:

security add-generic-password -a "$USER" -s OPENROUTER_API_KEY -w

Read it back only for the length of one command, so it never lingers in the environment:

OPENROUTER_API_KEY=$(security find-generic-password -s OPENROUTER_API_KEY -w) dotnet run orchat.cs

Check whether it is stored, without revealing it:

security find-generic-password -s OPENROUTER_API_KEY -w >/dev/null 2>&1 && echo FOUND

Update it after a rotation, and delete it:

security add-generic-password -U -a "$USER" -s OPENROUTER_API_KEY -w
security delete-generic-password -s OPENROUTER_API_KEY

The same pattern works for any local tool secret. Pick a service name with -s, and the rest is identical. It also works from cron and launchd, which is why it beats an interactive shell profile.

Running it

Use a wrapper. They exist because the secret store differs by platform, not because the app is awkward to start:

git clone https://github.com/solrevdev/solrevdev.orchat.git
cd solrevdev.orchat
./orchat.sh      # macOS and Linux
./orchat.ps1     # Windows, and anywhere else PowerShell runs

Each one finds the key, checks the SDK is version 10 or later, and hands over. If either check fails you get a plain message saying what to do rather than a stack trace. The key goes into the environment of the app and nowhere else.

Where they look for the key, in order:

orchat.sh orchat.ps1
1 OPENROUTER_API_KEY OPENROUTER_API_KEY
2 security (macOS keychain) Get-Secret (PowerShell SecretManagement)
3 secret-tool (Linux libsecret) security, then secret-tool

The environment is checked first, so you can override the stored key for one run. Set OPENROUTER_KEYCHAIN_SERVICE to read a different entry, which is useful if you keep a second key for testing.

The wrappers are a convenience, not a requirement. See Running it without the wrapper for the full command line.

Running it without the wrapper

The wrappers only find the key and check the SDK. Everything else is plain dotnet, so you lose nothing by calling it directly:

OPENROUTER_API_KEY=$(security find-generic-password -s OPENROUTER_API_KEY -w) dotnet run orchat.cs

Configuration

orchat takes no command-line switches. It is configured by two environment variables, and by the / commands once it is running.

Variable Default What it does
OPENROUTER_API_KEY none, required Your key. The app reads it only from here, never from a file.
OPENROUTER_MODEL openai/gpt-5.6-luna Model to start with. /model changes it afterwards.
OPENROUTER_KEYCHAIN_SERVICE OPENROUTER_API_KEY Read by the wrappers only, to pick a different keychain entry. The app ignores it.

So to start on a free model for a session:

OPENROUTER_MODEL=openai/gpt-oss-20b:free ./orchat.sh

dotnet options worth knowing

These are dotnet run options, and they work here the same as on any project:

dotnet run --file orchat.cs          # explicit form, needed if the folder has a project
dotnet run orchat.cs -c Release      # optimised build
dotnet run orchat.cs --no-build      # skip the build, reuse the last one, starts faster
dotnet build orchat.cs               # compile without running, to check it still builds

Running the .cs file directly

orchat.cs carries a #!/usr/bin/env dotnet shebang on its first line and is committed executable, so on macOS and Linux the source file is its own command:

OPENROUTER_API_KEY=$(security find-generic-password -s OPENROUTER_API_KEY -w) ./orchat.cs

.NET 10 ignores the shebang line when compiling, so this changes nothing about dotnet run orchat.cs. Windows has no shebang mechanism, so use orchat.ps1 there.

Converting to a normal project

If it ever outgrows one file, this is the supported exit route and it keeps the code intact:

dotnet project convert orchat.cs --dry-run   # see what it would do first
dotnet project convert orchat.cs

That trades away the one-file design, so read Design constraints first.

Publishing a standalone binary

dotnet publish turns the single file into a native executable that needs no .NET installed at all:

dotnet publish orchat.cs -o ./dist
./dist/orchat

The result on an arm64 Mac:

Path dist/orchat
Size 5.4 MB
Type Mach-O 64-bit executable arm64
Needs Nothing. No SDK, no runtime.
Startup 117 ms, against 685 ms for dotnet run orchat.cs

Both timings above cover the same work, including the /key network call at startup, so the difference is the build-and-launch overhead dotnet run carries and the published binary does not. If you use this daily, publish once and put dist/orchat on your PATH.

Two things to know.

It compiles ahead of time to native code. That only works because the app avoids the reflection-based JSON paths native compilation cannot see through. An earlier draft used JsonArray.Add<T>, which raised IL2026 and IL3050, and would have produced a binary that built cleanly and then failed at runtime the first time it sent a message. The fix was a cast, so the initialiser binds to the JsonNode overload instead:

var messages = new JsonArray { (JsonNode)new JsonObject { ["role"] = "system", ["content"] = system } };

If you extend the app and dotnet publish warns about trimming, fix it rather than suppress it.

The binary is platform specific and is not committed. dist/ is in .gitignore. The executable above runs on arm64 macOS only, it is 5.4 MB, and one command regenerates it, so committing it would bloat the history for something most machines could not run. Build it on the machine that needs it. If you ever do want it tracked, .gitattributes already has Git LFS configured, so add a rule for dist/orchat rather than committing it plainly.

How the conversation works

Anything you type that does not start with / is sent to the model. The reply streams back, followed by a line giving token counts, the cost of that turn, how long it took, and the running session total.

It is a real conversation, not a series of unrelated one-shot questions. Every turn sends the system prompt plus the whole thread so far, so you can ask a question, read the answer, and follow up with "why?" or "now do it in C#" and the model has all of it.

Nothing is trimmed or summarised. That keeps the context honest, but it means each turn costs a little more than the last, because the whole thread goes up as input every time. The in figure on the stats line after each reply is that growing input count, so you can watch it. Use /reset when a thread has served its purpose. It clears the history but keeps your model and system prompt.

If a request fails, including after streaming starts, the error is reported and the failed question is dropped from the thread. Partial output may already be on screen, but it is not added to the thread, so the next turn still sends a valid conversation.

Trying out models

This is where it earns its keep. The model is read fresh at each request, so /model switches mid-thread and the new model inherits everything said so far. Two ways to use that:

Same conversation, different models. Ask something hard, then hand the same thread to another model and ask it to critique the previous answer:

> explain why my C# async method deadlocks when I call .Result on it
...
> /models claude
> /models use 3
> the answer above is from another model. what did it get wrong?

Same question, clean slate, for a fair comparison:

> /system You are a terse expert. No preamble.
> what is the difference between a record and a class in C#?
...
> /reset
> /model openai/gpt-oss-20b:free
> what is the difference between a record and a class in C#?

/save writes the thread to a markdown file so you can keep the comparison. /models and /models free show what is worth trying, cheapest first, and the per-turn cost line tells you what each model actually charged rather than what its price card implies.

Commands

Command What it does
/model Show the current model
/model <id> Switch model, for example /model openai/gpt-oss-20b:free
/models List the catalogue, cheapest output price first
/models <text> Filter by substring on id or name, for example /models claude
/models free Zero priced models only
/models <n> Full detail for row n of the last listing
/models use <n> Switch to row n
/key Usage figures for the current key, to six decimal places
/cost What this session has spent so far
/system Show the system prompt
/system <text> Replace the system prompt
/reset Clear the thread, keep the model and system prompt
/save [file] Write the thread to markdown, default orchat-<timestamp>.md
/exit Quit

The catalogue is fetched once and cached for the session, so browsing costs nothing after the first call. If /save cannot write its file, it reports the error and keeps the session open.

It reads from standard input, so you can script it:

printf '%s\n' 'hello, reply with one short sentence' '/key' '/exit' | ./orchat.sh

Reading the catalogue yourself

GET /api/v1/models is public and needs no key. One trap if you write something similar: pricing comes back as strings, mostly.

"pricing": { "prompt": "0.000003", "completion": "0.000015" }

Mostly. Some entries omit pricing, and some quote it as a number. GetValue<string>() throws on a number rather than converting, so a naive parser takes the whole listing down over one odd row. Check the kind first. Parse strings with the invariant culture because OpenRouter always uses a dot as the decimal separator:

static double Price(JsonNode? n) => n?.GetValueKind() switch
{
    JsonValueKind.String => double.TryParse(
        n.GetValue<string>(),
        NumberStyles.Float,
        CultureInfo.InvariantCulture,
        out var v) ? v : 0,
    JsonValueKind.Number => n.GetValue<double>(),
    _ => 0
};

Same for context_length, which is missing on a few entries.

Proving the credit timer reset

This is the part that took the longest to get right, so it is worth stating plainly.

GET /api/v1/key reports usage for one key, not for the account. If you generate a fresh key, it reads zero no matter how much the account has spent, and no matter what you did in the web Chatroom. Do not read those zeros as "no activity". /key in this tool shows all-time, daily, weekly and monthly usage, plus the free-tier flag, which is enough to watch your own requests land.

GET /api/v1/credits is the account-level witness. It returns total_credits and total_usage to nine decimal places, so a sub-penny call is visible:

curl -s -H "Authorization: Bearer $(security find-generic-password -s OPENROUTER_API_KEY -w)" \
  https://openrouter.ai/api/v1/credits

The method that actually proves a reset is a before-and-after delta. Snapshot total_usage, send one small paid request, wait a minute or two for OpenRouter to aggregate, and snapshot again. A rise proves inference ran against your credits.

Two warnings. Free models with a :free suffix spend nothing, so they may not count as the billable activity that resets expiry. Always use a paid model for this. And the free catalogue churns hard, so never hard-code a :free id into a scheduled job.

Results, 3 August 2026

Run against an account holding one purchase of $10.00 from March 2024, using a freshly generated key that started at zero. Three paid requests were sent through anthropic/claude-sonnet-4.5.

Account level, from /api/v1/credits:

before   total_usage  0.069429591    available  9.930448639    displays as $9.93
after    total_usage  0.092981691    available  9.907018309    displays as $9.91

Key level, from /key:

start      usage 0.000000   daily 0.000000
16:41:40   usage 0.000255   daily 0.000255
16:42:58   usage 0.000377   daily 0.000377
final      usage 0.023808   daily 0.023808   free_tier false

Both endpoints moved, independently, and the two figures agree: the account rose by $0.023552 and the key by $0.023808, the small gap being one request that landed before the first snapshot. Total spend for the exercise was under two and a half pence, and the balance moved from $9.93 to $9.91, so the reset is now visible in the browser as well as in the API.

Aggregation lagged by roughly one to two minutes. A reading taken straight after a request will understate it, so poll again before concluding anything.

Files

File What it is
orchat.cs The whole application. Executable, so ./orchat.cs runs it
orchat.sh Launcher for macOS and Linux
orchat.ps1 Launcher for Windows and PowerShell
dist/ Published binaries. Ignored by git, created by dotnet publish

Design constraints

Worth keeping if you extend this:

  • One file of C#. No .csproj, no src/ folder, no test project. The two launchers are shell scripts and do not change that.
  • No NuGet packages. Everything used is in the shared framework, which is what lets dotnet run orchat.cs work with nothing else on disk.
  • Never print, log or commit the API key. The tool masks the partial key OpenRouter returns as a label, and reads the value only from the environment.
  • Keep the usage reporting to plain numbers. No dashboard, no charts.

What is next

  • A /compare command that sends one prompt to several models and prints the answers with their costs side by side.
  • Token counts per model in /cost, so the session summary shows where the money went.
  • Nothing scheduled. OpenRouter emails before credits expire, so the email is the reminder and a cron job would be one more thing to maintain.

Licence

MIT.

About

A single-file .NET 10 console client for OpenRouter. Multi-turn chat, model browsing by price, and real per-turn costs.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages