API reference
If your code already talks to OpenAI, it already talks to TokenMercato. Change the base URL and add a price ceiling.
Drop-in client
import os
from openai import OpenAI
client = OpenAI(
base_url="https://tokenmercato.com/api/v1",
api_key=os.environ["AITRADER_KEY"],
)
stream = client.chat.completions.create(
model="llama-3.3-70b-instruct",
messages=[{"role": "user", "content": "Explain a limit order book."}],
stream=True,
extra_body={"max_price_per_mtok": 0.40},
)
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="")Writing the integration with an agent
The whole API as one plain-text file
Endpoints
| POST | /v1/chat/completions | OpenAI-compatible. Spends a fill you already own, or takes the best ask under your ceiling. |
| GET | /v1/orders | Your orders, newest first. Open by default; ?status= reads the rest. |
| POST | /v1/orders | Post a resting bid, or an ask if you run a node. |
| DELETE | /v1/orders/{id} | Cancel and release unfilled escrow. |
| GET | /v1/book | Order book snapshot for one model. |
| GET | /v1/models | Tradeable contracts, mid price and live capacity. |
| GET | /v1/jobs | Every job served to you, newest first. |
| GET | /v1/jobs/{id} | Metered tokens, cost, and verification state. |
| SSE | /v1/stream/book | Book snapshots, pushed only when they change. |
Post a resting bid
For batch work that can wait for a better price
POST /v1/orders
Authorization: Bearer sk_live_...
{
"side": "bid",
"model": "llama-3.3-70b-instruct",
"limit_per_mtok": 0.372,
"size_tokens": 12000000,
"expires_in": 86400
}
201 Created
{
"id": "bd_8812",
"status": "open",
"filled_tokens": 8400000,
"remaining_tokens": 3600000,
"avg_fill_per_mtok": 0.368,
"escrow_locked_usd": 1.34,
"jobs": ["jb_4c1f"]
}An agentic loop
Tools, tool results, and who runs the turn
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current conditions for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
messages = [{"role": "user", "content": "Is it raining in Oslo?"}]
while True:
stream = client.chat.completions.create(
model="llama-3.3-70b-instruct",
messages=messages,
tools=tools,
stream=True,
extra_body={"max_price_per_mtok": 1.20}, # agentic floor is 1.00
)
# Fragments arrive on delta.tool_calls, keyed by index, exactly as OpenAI sends them.
text, calls = accumulate(stream)
messages.append({"role": "assistant", "content": text, "tool_calls": calls})
if not calls:
break
for call in calls:
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": run(call.function.name, call.function.arguments),
})Running an agent tool you did not write
The localhost proxy, for clients with no room for a price ceiling
# One download, one command. Any OpenAI client then works unmodified.
ai-trader proxy --buyer-key sk_... --max-price 1.20
base_url http://127.0.0.1:5120/v1
api_key anything; this proxy bills sk_...
ceiling $1.20/Mtok on every request
# So, for anything that reads the usual environment:
export OPENAI_BASE_URL=http://127.0.0.1:5120/v1
export OPENAI_API_KEY=unusedYour own agent, on your own machine
The OpenAI Agents SDK, pointed at the proxy — tools run locally
# Shell 1 — the proxy carries the price ceiling the OpenAI schema has no field for.
# --max-price must clear the $1.00/Mtok agentic floor, or tool turns are refused.
ai-trader proxy --buyer-key sk_... --max-price 1.20
# Shell 2 — your agent. The loop and the tools run here, on your machine.
# pip install openai-agents
from agents import (
Agent, Runner, function_tool,
OpenAIChatCompletionsModel, set_tracing_disabled,
)
from openai import AsyncOpenAI
# Point the SDK's client at the proxy. Any key: the proxy bills its own.
client = AsyncOpenAI(base_url="http://127.0.0.1:5120/v1", api_key="unused")
# The SDK's default tracing uploads runs to OpenAI's platform under OPENAI_API_KEY.
# You are not talking to OpenAI, so turn it off rather than leak a run and 401.
set_tracing_disabled(True)
@function_tool
def read_file(path: str) -> str:
"""Return the contents of a file on this machine."""
with open(path) as f: # runs on YOUR box — the venue never sees it
return f.read()
agent = Agent(
name="assistant",
instructions="Use the tools when they help; answer briefly otherwise.",
# OpenAIChatCompletionsModel, NOT the default: the venue speaks
# /v1/chat/completions, not the Responses API the SDK reaches for first.
model=OpenAIChatCompletionsModel(
model="llama-3.3-70b-instruct",
openai_client=client,
),
tools=[read_file],
)
result = Runner.run_sync(agent, "Summarise ./notes.md in two lines.")
print(result.final_output)Read the book
GET /v1/book?model=llama-3.3-70b-instruct&depth=12
{
"model": "llama-3.3-70b-instruct",
"quant": "Q4_K_M",
"mid_per_mtok": 0.380,
"spread_per_mtok": 0.009,
"bids": [{ "price": 0.3755, "size_tokens": 1840000, "orders": 3 }],
"asks": [{ "price": 0.3845, "size_tokens": 2110000, "orders": 4 }],
"as_of": "2026-09-07T14:00:00Z"
}The one extra parameter
max_price_per_mtok is a hard ceiling in USD per million output tokens. If no node can serve the request at or under it, the call returns 409 price_too_low rather than filling higher. A marketplace that silently charges past your limit is not a marketplace, so the failure is loud on purpose.
The ceiling bounds the fill, not the invoice. The buyer fee is charged on top of what clears, so budget against total_usd in the final frame rather than against your own number.
It is required, not defaulted. A request with no ceiling is a request with no bill you agreed to in advance, so omitting it returns 400 missing_price_ceiling instead of quietly taking whatever the book is asking.
It does not have to be in the body. Send it as the x-aitrader-max-price-per-mtok header instead and it bounds the request exactly the same way. That exists because most agent tooling builds its request body from the OpenAI schema, which has no field for a price — so without a header there is nowhere for those clients to bid from, and they cannot use this venue at all. The body field still wins where both are sent: the header is a standing default for the connection, the field is what one request chose.
Sizes are in tokens
Prices are quoted per million tokens because that is the unit the industry already reads, but every size on the wire is a whole number of tokens. Fractional tokens do not exist, and a venue that accepts size_mtok: 0.0000004 has to decide what to do with the remainder. Ours never has to.
A resting bid is capacity you already own
When a bid fills, the tokens are bought: the money is escrowed and a node is committed to serving them. What is missing is a prompt. Every /v1/chat/completions call looks for one of your unspent fills for that model before it goes near the book, cheapest first, and only crosses the spread when it finds nothing. That is the point of resting a bid overnight — you are buying at your price and spending it later.
A fill bought above the max_price_per_mtok on the current request is skipped rather than spent. You still own it; this request simply said it would not pay that much. Unspent fills are released back to your balance after thirty minutes, so a bid is capacity with a shelf life, not a permanent claim.
A resting bid is only the plainest of several shapes. It can expire on a deadline, carry a batch of prompts, or climb its own price until it fills. See Order types for the full set and the growing_expense block.
Retrying a bid safely
POST /v1/orders accepts an Idempotency-Key header. Replaying a request with the same key returns the original response instead of posting a second order, which is what you want when a timeout leaves you unsure whether the first one landed. The key is scoped to your account and bound to the exact body you sent: reusing it with different contents returns 422 idempotency_key_reuse rather than quietly answering for the wrong order. A key burned on a request that failed validation is released, so you can fix the body and send it again under the same key.
Streaming and settlement
stream: true is required, and responses come back as normal SSE. A buffered completion would send nothing until the answer was finished, leaving your client waiting the whole job for response headers — and most HTTP clients, Node’s fetch among them, give up at 300s. Jobs here routinely run longer, so a request without stream: true is refused with 400 unsupported_parameter.
Billing is on tokens the venue counted, not tokens the node reported, so a stream you abort part way through settles for the part you received. The response carries an x-aitrader-order header holding the id this request was booked under. Read it back at /v1/orders/{id}, or at /v1/jobs/{id} in the case where the request never became an order, to see the metered count, the node that served it, and whether it was selected for audit. Try the order route first — a job id only appears here when there was no order to name.
Agent tools you did not write
Between the ceiling and the mandatory stream, a client you cannot edit may have no way to call this venue — plenty of agent CLIs build a fixed request body and never stream. The client download carries a proxy command for exactly that: it serves an OpenAI-compatible endpoint on loopback, attaches your ceiling to every request, always streams upstream, and folds the stream back into a single JSON completion for a client that asked for one. Point the tool at http://127.0.0.1:5120/v1 with any API key and it works unmodified.
Your buyer key stays in the proxy rather than in the tool’s config, and whatever key the tool sends is ignored. One caveat worth knowing: a buffered client is still waiting on headers for the whole job, so its own timeout — not ours — becomes the ceiling on how long a completion may run. A client that streams has no such limit. Hanging up cancels the job at the venue rather than paying out a stream nobody is reading.
It serves three routes and refuses everything else: POST …/chat/completions, GET …/models, and a GET /health. The /v1 is optional on all of them, because clients disagree about whether it belongs to the base URL or the path they append.
It will not serve a browser. The proxy holds a key that spends money on a port any page you visit can address, so it requires content-type: application/json — which forces a cross-origin caller to preflight — and answers no preflight, and refuses outright anything carrying an Origin header. Without that, a site you happened to open could run up a bill on your key. Command-line and desktop tools are unaffected; a tool that runs inside a browser tab should call the venue directly with its own key, which is what the CORS headers on this API are for.
Your own agent, running on your machine
The venue is a plain completions endpoint, so an agent framework drives it the same way it would drive OpenAI: your loop sends the messages and the tool definitions, gets back the calls the model wants, runs them, and sends the results on the next turn. The loop and every tool run stay on your computer — we serve the tokens and never see the read_file above execute.
Two adjustments make an off-the-shelf framework work, and both are in the example. Point its client at the proxy, which carries the price ceiling the OpenAI schema has no field for. And pin it to the chat-completions model class: OpenAIChatCompletionsModel in the Agents SDK, litellm’s openai/ prefix elsewhere. The SDK reaches for OpenAI’s newer Responses API by default, which this venue does not implement; left on the default you get a 404 before a single token is billed.
Turn the SDK’s tracing off. It uploads each run to OpenAI’s platform under OPENAI_API_KEY, and since you are not talking to OpenAI that is at best a failed upload and at worst your prompts leaving the machine you were keeping them on.
One caution specific to a host you do not control: the node picks the call your machine runs. The model asks for a tool, but a hostile node can return a different call — a read_file of your keys, a shell command you never wrote. This is the accepted-risk trade behind the whole agentic venue, and on your own machine it means the same thing every agent framework already tells you: gate anything that writes or executes behind your own confirmation, and keep read-only tools read-only. The bad answer is refunded when you catch it; the command already run is not.
Why a completion stops, and what you pay for a short one
Read finish_reason on the last choice to know which of these happened. In every case you are billed only for the output tokens the venue metered, never for the ones you asked for and did not get.
stop— the model finished on its own. This is the ordinary case.length— the stream hit a token ceiling and was cut off. That ceiling is the smaller of yourmax_tokensand the size of the fill serving the request (see below). Alengthfinish is your signal to send a follow-up request if you want the rest.tool_calls— the model wants a tool run before it carries on. The calls are yours to execute; see below.null— the serving node never said why it stopped, which happens on an older node client or a connection cut in transit. We report it rather than guessing: an answer that ended because it was severed looks exactly like one that ended because it was finished, and calling the first onestopwould hand you a fragment labelled complete. Treat it as unknown and check the text before relying on it.
The reason comes from the backend that generated the text, not from comparing the token count to the budget. Those are not the same thing: a model that happens to finish on the last token it was allowed is indistinguishable, by count alone, from one cut off there. The same value appears as a plain boolean on truncated, which is null when the reason is unknown.
max_tokens defaults to 4,096 and is capped at 32,768; a larger value is clamped to the cap rather than rejected. A request for more tokens than any one ask holds is served across several fills in sequence where the backend can extend a partial answer, and by the largest single fill where it cannot — in that case you are charged for what was produced, the unspent escrow is returned, and the request finishes length rather than erroring, so a supply-limited fill still gives you tokens rather than nothing.
Parameters
messages keeps its roles all the way to the model: system, user and assistant turns are passed through so the model's own chat template applies to them. response_format with {"type":"json_object"} constrains decoding to valid JSON, and stop takes up to four sequences. Both are handed to the backend rather than applied afterwards.
Sampling is yours to set. temperature (0–2), top_p (0–1), top_k, seed, frequency_penalty and presence_penalty (−2 to 2) are passed to the backend exactly as you send them. Anything you leave out keeps the default the model ships with, which samples — so two identical requests need not come back identical unless you ask for temperature 0. A value outside those ranges comes back as 400 invalid_sampling before any hold is placed on your balance, rather than failing the job after you have paid for it.
Batch commands take the same fields, and each command reads its own. One bid can carry a hundred unrelated prompts, and the settings that suit an extraction are not the ones that suit prose, so there is no single setting for the order. The 400 names the position of the command that was wrong rather than leaving you to find it.
One caveat on seed: it makes an answer repeatable on the same node running the same weights, and the venue does not route on it. The same seed sent twice can land on different hardware and produce a different answer, so treat it as a way to pin one node down, not as a guarantee from us.
If the serving node vanishes mid-stream you receive an in-band node_dropped error after the tokens it did send. Those tokens are billed, the rest of the hold is refunded, and the retry costs you nothing you were not already going to pay. A venue restart that strands a stream is swept and refunded the same way, within a three-hour lease.
Tools, and who runs the loop
tools and tool_choice take the OpenAI shapes: an array of {"type":"function", "function":{name, description, parameters}}, and either auto, none, required, or a named function. Up to 64 tools per request; a malformed entry is 400 invalid_tools and names the index. When the model asks for one, the calls arrive whole on delta.tool_calls in their own frame, which still carries finish_reason: null. The frame after it is the terminal one: empty delta, the real finish_reason, usage and billing. So read the calls off the frame that has them rather than off the last frame before [DONE] — that one is the receipt, and its delta is empty.
You run the tool. The venue executes nothing, sandboxes nothing, and keeps no memory of the turn — a completion here is one fill, bought and settled. So an agentic loop is you sending the transcript back with the result appended: the assistant turn that asked, replayed with its tool_calls, then a role: "tool" message whose tool_call_id says which call it answers. That id is required on a tool turn: without it a parallel call is ambiguous, and guessing the pairing would be a worse failure than 400 invalid_messages. Send back the id we gave you — the venue names calls itself rather than letting a node choose, so two calls can never collide on one.
Each pass through that loop is a fresh request that finds its own fill. It can land on a different node than the last one, which is fine for a stateless transcript and is the reason nothing is cached between turns. Your tool schemas ride along every time, so they are counted as prompt tokens on every turn. That reaches the bill — prompt tokens are billed at a quarter of the output price — and it counts against the context the serving card can hold. A large toolset on a long transcript is what pushes a request into 400 context_length_exceeded, and what makes a trimmed toolset worth the effort on a loop that runs many turns.
A turn that runs out of max_tokens partway through a call finishes length, not tool_calls, and the half-written call comes back anyway with its arguments cut where the budget ran out. Relabelling that a call would hand you truncated JSON with no hint as to why it failed to parse; length tells you to retry the turn with a larger budget.
Not every host can call tools — an older node client has no tool channel, and some inference servers offer tools but no way to compel one. The venue routes agentic work only to hosts that can serve it rather than letting one answer your request in prose, so when none is online for your model you get 409 no_tool_supply instead of a fill. It clears as tool-capable supply comes online.
The agentic floor
An agentic request bids at least $1.00/Mtok. Below that it is refused with 400 price_below_agentic_floor before any escrow is taken, rather than being quietly raised to the floor — we are not going to spend more of your money than the number you sent.
The floor is on your bid, not on what you pay. Fills clear at the ask, so a $1.20 ceiling against a $0.31 ask still settles at $0.31; the floor decides who may enter this book, not what the winner charges. Tool calling is the part of the workload where a cheap node that answers badly costs you the most — a mangled call burns the turn and every turn after it — and a floor is the bluntest honest way to keep that supply out.
A request counts as agentic if it carries tools or any role: "tool" message. Both, deliberately: dropping the schemas on the final summarising turn would otherwise buy the whole loop at ordinary prices. An ordinary completion is untouched by this and can still bid a fraction of a cent.
Escrow behind the loop
On the larger models an agentic request also has to have money standing behind it. Where min_agentic_escrow_tokens on GET /v1/models is non-zero, a request carrying tools is refused with 400 below_agentic_escrow_floor unless your account already holds that many tokens on the model: the unfilled remainder of your open bids, plus fills you have bought and not yet spent, with the order this request would place counted in. Models that report 0 have no such floor.
The reason is the shape of a loop rather than the size of any one turn. Each step re-sends the entire transcript, so the prompt grows while the reply budget does not, and the spend over a run climbs far faster than its number of steps. A loop funded one turn at a time can therefore start, pay for forty transcripts, and stop on the forty-first with nothing to show — the worst outcome available, and one you find out about only after paying for it.
So the floor asks for the run's budget up front, as a single bid through POST /v1/orders. That is not only a solvency check: the fills that bid buys are capacity you own, and every turn after the first redeems them instead of crossing the spread again. Whatever the run does not use is released when the order expires or you cancel it, so the floor costs you the use of the money for the length of the run and nothing else.
Reading back what you spent
Nothing is thrown away, but the default view makes it look as though it is. GET /v1/orders answers with your resting orders only, which is what a client polling for fills wants — and an immediate-or-cancel bid is filled or cancelled the moment it resolves, so it leaves that view straight away. Pass ?status=filled, cancelled, expired or all to read the history back. The default was left alone so that adding it could not change what an existing integration sees.
GET /v1/jobs is the same idea one level down. The x-aitrader-order header only helps for the responses you kept; this enumerates the ones you did not, with our metered token count, the node that served each, the fee charged on top, and the verification state. That is the difference between being able to audit the bill and having to take it on trust.
Both take page and page_size (50 by default, clamped to 200) and answer with page, page_size, page_count, total and data. Both are scoped to the key that authenticated: there is no parameter that points either at another account. An unrecognised status is 400 invalid_status rather than a silently empty page.
Errors worth handling
409 price_too_low— hosts are online, but the cheapest asks more than your ceiling. This is the one to branch on: raise the limit or post a resting bid and wait. The message names the cheapest ask, so you can decide how far to move.409 no_supply— no host is online for that model at all. Waiting will not help as quickly as picking another model.409 no_fill— rarer than it looks: supply was inside your limit and was taken or withdrawn in the instant the order was placed. A straight retry is usually right.404 unknown_model— the model id is not one the venue lists.GET /v1/modelsis the authority.402 escrow_insufficient— the bid needs more funded balance than you hold.429 rate_limited— too many requests on this key. Back off and retry.400 invalid_sampling— a sampling value fell outside its range.invalid_messages,invalid_tools,invalid_stopandinvalid_response_formatare the same shape of complaint about the other fields.400 unsupported_parameter—stream: truewas missing.400 context_length_exceeded— the transcript plus the reply you asked for is longer than the model can hold. Agentic loops meet this one as history accumulates.400 price_below_agentic_floor— a request carrying tools or a tool result bid under $1.00/Mtok.400 below_agentic_escrow_floor— an agentic request on a model that wants escrow behind the loop, from an account not holding enough of it. Post a bid for the run and retry.409 no_tool_supply— hosts are online for the model, but none of them can call tools. Retry shortly, pick another model, or send the request without tools.- A node vanishing mid-answer is not in this list because it cannot be a status code: you already had a 200 when the stream opened. It arrives as a
node_droppederror frame instead, described above.
Every endpoint above is implemented and answers on the development venue. The node client is live, so a real machine can register, benchmark, and fill these orders end to end; the seeded book fills the rest of the depth.