Agentic Proxy Quickstart (Phase 24)

yaarbal proxy is a loopback OpenAI-compatible server for agentic coding tools (Cline, or any client that speaks openai-python's wire shape). It holds your own Ed25519 spend key, signs its own settles, parses tool calls exactly as your own node emitted them, and routes tool-carrying traffic only to the node you own.

1. What you get

A local server at http://127.0.0.1:7100/v1 that:

What it is not:

2. Install

The proxy is part of the yaarbal wheel. Install it the way the CLI Quickstart §1 describes — the wheel (yaarbal-<version>-py3-none-any.whl) plus the installer that comes with it, no source checkout needed:

YAARBAL_WHEEL=./yaarbal-0.1.0-py3-none-any.whl bash install.sh
source ~/.rentmygpu-venv/bin/activate

On Windows: .\install.ps1 -Wheel .\yaarbal-0.1.0-py3-none-any.whl. Do not pass --no-seeder / -NoSeeder: the proxy needs the seeder extra. (Team members with a source checkout: pip install -e ".[seeder]" with the llama-cpp-python index for your platform, or scripts/install.sh.)

That extra pulls in flask, structlog, and llama-cpp-python — the proxy reads the model's tokenizer.chat_template straight out of the GGUF's own metadata, so the model file must be present locally for any tool-carrying request:

yaarbal models pull qwen3.5-9b-q4_k_m

(Plain chat does not need the file locally — see §9.)

3. Run

yaarbal proxy

Defaults: binds 127.0.0.1:7100 (never any other interface — loopback-only is a security property, there is deliberately no --host flag), auto-discovers a model per request if none is specified, and ships with thinking off by default (§5).

Flags:

The API key: generated on first run, saved 0600 at ~/.rentmygpu/proxy-key, and printed exactly once. Reprint it any time with:

yaarbal proxy --show-key

It never leaves the machine and is not a network credential — it is only what your local client uses to authenticate to your own loopback server.

4. Point a client at it

OpenAI SDK

from openai import OpenAI

client = OpenAI(base_url="http://127.0.0.1:7100/v1", api_key="rmgp-...")

client.models.list()

resp = client.chat.completions.create(
    model="qwen3.5-9b-q4_k_m",
    messages=[{"role": "user", "content": "Say hi"}],
    stream=True,
)
for chunk in resp:
    print(chunk.choices[0].delta.content or "", end="")

resp = client.chat.completions.create(
    model="qwen3.5-9b-q4_k_m",
    messages=[{"role": "user", "content": "List files in the current directory."}],
    tools=[{
        "type": "function",
        "function": {
            "name": "list_files",
            "description": "List files in a directory",
            "parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
        },
    }],
)
print(resp.choices[0].message.tool_calls[0].id)  # 32-char id

Cline

In Cline's settings: provider "OpenAI Compatible", base URL http://127.0.0.1:7100/v1, API key from yaarbal proxy --show-key, model id qwen3.5-9b-q4_k_m.

Keep Cline's own tool-approval gate switched on. It is the real safety boundary here, not this proxy. Spike 048's open finding stands: the parser cannot distinguish an injected tool call from one the model genuinely decided to make — this proxy only narrows who can produce the call (your own node), not whether any given call is safe to execute. Never configure Cline to auto-approve tool execution against this proxy. See "Widening beyond your own node" (§6) before allowlisting any peer or enabling the trusted opt-in — manual approval is required for both.

5. Thinking is off by default (and how to turn it on)

enable_thinking defaults to false. Measured reason (spike 047d, 32 trials each way, single-turn): disabling the model's <think>...</think> reasoning preamble cuts paid output tokens 68.8% at 0.0 percentage points of accuracy change, and turns a 100k-token welcome grant into roughly ~521 agentic steps instead of ~162 — about 3.2x more completions from the same credits. Thinking is a real-money cost lever, not a quality one, on this workload.

Re-measured over long multi-turn agentic loops (Phase 27 VALD-03, decided in docs/VALIDATION-REPORT.md section 9 against section 5's numbers): spike 047's single-turn result holds. A paired A/B over 18 real agentic-loop runs (9 per arm, three tasks, enable_thinking alternating inside every task/rep pair) measured pass_rate 9/9 vs 9/9 (0.0 pt delta) and a 70.6% paid-token reduction (4,536 vs 15,430 billed completion tokens over the same 78/91 turns). The decision was to keep the default off — the two qualifications the report itself insists on being read alongside the win: (1) both arms scored the maximum on the three measured tasks, so this A/B confirms enable_thinking=false costs no accuracy on these tasks, not that the two settings are equally accurate on a harder or longer task set; (2) protocol fidelity favoured thinking off too — 0 degraded turns in 78 vs 13 in 91 with thinking on (11 of those missing: the model reasoned and then didn't call, recovered every time by the harness's client-side nudge). Cost also grows with loop length under thinking-on (169.56 tokens/turn here vs 123 on spike 047's single turns) — the longer the loop, the more the on-arm reasons per turn.

Three opt-outs, in precedence order (highest wins):

  1. Per-request, top-level: {"enable_thinking": true, ...}, or the vLLM-style nested form some clients send: {"chat_template_kwargs": {"enable_thinking": true}}.
  2. Per-process, CLI flag: yaarbal proxy --thinking.
  3. Per-process, environment: RENTMYGPU_PROXY_THINKING=true.

When thinking is on, reasoning never appears inside content. It streams and completes separately as reasoning_content (both as a delta field on streaming chunks and as a top-level message field on non-streaming responses) — treat it as scratch work, never as the answer.

6. Routing and refusals

The routing gate runs at peer selection, before any prompt byte leaves the machine. It applies only to requests that carry tools — plain chat is ungated and reaches the whole fleet, including nodes you do not own.

A tool-carrying request is refused outright, never silently downgraded to a stranger's GPU and never silently answered as plain chat with tools stripped. Every error is an OpenAI-shaped error object (type: invalid_request_error or server_error) naming the code:

Code HTTP When What to do
invalid_api_key 401 Missing/wrong Authorization: Bearer header Use the key from yaarbal proxy --show-key
model_not_found 404 No model specified and none reachable, or the named model has no live peer Pull/check the model, or omit model to auto-discover
own_node_offline 403 Your own earn identity exists but is not currently announcing this model Start your seeder (yaarbal seeder), or wait for it to announce
not_own_node_and_not_allowlisted 403 Tools requested against a peer that is neither your own node nor on your agentic allowlist Run your own seeder, or deliberately allowlist that peer_id with yaarbal proxy --allow-peer <peer_id> (see "Widening beyond your own node" — manual approval required)
trusted_but_not_sufficient 403 Tools requested against a seeder that holds the ledger trusted flag while --allow-trusted-seeders is off. This is not a judgment about that seeder: trusted is escrow-release probation, self-granted in about two minutes at zero net cost, and cannot authorize code execution on your machine Allowlist the peer_id explicitly, or read the opt-in section before enabling --allow-trusted-seeders
insufficient_credits 402 Your spend balance cannot afford even 1 token at this model's tier Top up, or lower max_tokens
chat_template_unavailable 400 Tool calling needs the model's own GGUF locally and it isn't pulled yaarbal models pull <id>
context_length_exceeded 400 The history no longer fits even after trimming (Phase 26 CTX-03, see below), or the seeder's own context ceiling Shorten the live message, or use a seeder that advertises a larger n_ctx
seeder_busy 503 The single-inference slot is occupied Retry (a Retry-After header is set)
seeder_unreachable 502 Transport error talking to the seeder Check the seeder is running; the proxy drops its cached session on this error
seeder_error 502 The seeder returned a non-200 the proxy could not otherwise classify Check the seeder's own logs
upstream_stream_ended 502 The SSE stream from the seeder died mid-generation Already-generated tokens were delivered and billed; retry the request
tracker_unreachable 502 Transport or non-JSON error talking to the tracker Check tracker connectivity
malformed_tool_call — Not an error response. The model emitted a tool call the parser could not accept (a hallucinated/undeclared function name, an undeclared or mistyped parameter, a call cut off mid-stream). The turn ends as a normal completion with no tool_calls and the details in rentmygpu.tool_call_rejection — see "Malformed tool calls" below Read rentmygpu.tool_call_rejection.declared_tools; re-prompt with the valid names, or lower the tool count
tool_choice_not_honored 502 tool_choice: required (or a named function) but the model answered in plain text instead Retry
invalid_tools 400 tools is present but malformed Fix the request body
invalid_tool_choice 400 tool_choice is not auto/none/required/a valid named-function object Fix the request body
invalid_messages 400 messages is missing, empty, or a message has no string role Fix the request body
invalid_max_tokens 400 max_tokens present but not an int ≥ 1 Fix the request body
unknown_tool_call_id 400 A tool message's tool_call_id does not match any call in the nearest preceding assistant turn Only reply to tool_calls from the immediately preceding assistant turn
invalid_tool_call_arguments 400 A prior assistant turn's tool_calls[].function.arguments is not valid JSON Ensure history round-trips the exact JSON string the proxy emitted
tool_message_without_prior_calls 400 A tool message appears with no preceding assistant tool_calls turn at all Fix conversation ordering
unsupported_content_part 400 A message uses a content-part shape the proxy does not render (e.g. non-text) Send plain string content
e2ee_identity_mismatch 502 The seeder's encryption key rotated mid-session in a way the one allowed renegotiation could not resolve Retry; a fresh session will renegotiate
e2ee_stream_tamper 502 A decrypted frame failed integrity verification Retry; treat as a transport-layer fault, not your prompt
e2ee_unsupported_peer 502 The peer does not support the encrypted transport the proxy requires Choose a different peer
e2ee_required 502 The peer refused to negotiate encryption at all Choose a different peer

Busy behaviour under contention (Phase 27 SUPL-01)

The seeder serves one generation at a time (BoundedSemaphore(1), §"Routing and refusals" above). A second request that arrives while the slot is held queues silently — no bytes at all — until either the slot frees up (served normally, 200) or the seeder's own queue timeout expires (seeder_busy, 503, with a Retry-After header).

Measured (docs/VALIDATION-REPORT.md sections 2 and 6): a contender queued behind a short (~6.8s) occupier waited 5,042 ms, then was served normally. A contender queued behind a long (~40.8s) occupier received zero bytes for 30,045 ms before the busy answer — the full prior fixed 30s queue timeout. Separately, a sweep of 1–6 concurrent short agentic sessions against one seeder measured zero busy turns at every point, with the worst single queued wait 5,472 ms (p95 4,863 ms) at n=6 — well under either timeout.

Decided (section 9): the queue timeout is lowered to a 15.0s default, about 2.7x the measured 5.5s worst-case queued wait above, while halving the prior silent-wait ceiling. It is now env-configurable per seeder node via RENTMYGPU_SEEDER_QUEUE_TIMEOUT (float seconds; invalid, non-positive, non-finite, or above-threading.TIMEOUT_MAX values fall back to 15.0 with a warning) — an operator can restore 30s, or choose any other value, without a code change. The busy response shape itself (503 {"error": "busy", "retry_after": 5}, Retry-After: 5 header) is unchanged; only the wait before it moves. One consequence of a lower default, stated plainly: a session that would have been served after 15–30s of queued wait under the old default now gets a 503 instead — the ceiling above the measured n=6 sweep point remains unmeasured under the new default.

History trim (Phase 26 CTX-03). Before context_length_exceeded fires, the proxy first tries to make the conversation fit: it drops the OLDEST whole turn first (a tool_calls assistant turn and its tool result travel together, never split) against a budget derived from the serving seeder's advertised n_ctx, and re-renders. The system message and your current (live) message are never dropped. context_length_exceeded now means the history no longer fits even after every droppable turn has been dropped — a genuinely unrecoverable request, not just a long one. A trim is logged (proxy.history_trimmed, counts only) and is never written into the response — it never appears in the completion text or the SSE stream.

Three generic Flask-level errors can also occur outside the routing/completion path: not_found (404, unknown path), method_not_allowed (405, wrong HTTP verb on a known path), and payload_too_large (413, request body over the server's size cap).

Widening beyond your own node

Manual approval is required for ANY widened configuration. Your own node is the only configuration with no untrusted producer. Widening WHICH seeder may receive tool-carrying traffic — via the allowlist or the trusted opt-in below — never widens WHO approves what a tool call does. The proxy never configures, signals, or documents an auto-approval of side-effecting tools against a node you do not own; that decision stays entirely in your agentic client, every time, for every non-owned node (see "Keep Cline's own tool-approval gate switched on" above — this section reinforces that passage, it does not soften it).

Allowlisting a peer. The allowlist is a small JSON file at ~/.rentmygpu/agentic-allowlist.json (override with RENTMYGPU_PROXY_ALLOWLIST_PATH), managed with three CLI subflags:

yaarbal proxy --allow-peer <peer_id>    # add a peer_id, print the new count, exit
yaarbal proxy --deny-peer <peer_id>     # remove a peer_id, exit
yaarbal proxy --list-allowed            # print the allowlist, one peer_id per line, exit

Changes take effect on the next tool-carrying request without restarting the proxy — the file is re-read per request, not snapshotted at process start. Removing an entry refuses the next request again with not_own_node_and_not_allowlisted, immediately, no restart. A malformed allowlist file is treated as empty — never widened — and logs a proxy.allowlist_load_failed warning once per broken file version. Allowlisted peers are still not your own node: every completion they serve carries X-RentMyGPU-Require-Approval: true (below), and manual approval in your client is still required.

The trusted opt-in. --allow-trusted-seeders (or RENTMYGPU_PROXY_ALLOW_TRUSTED) is off by default and makes ledger-trusted seeders eligible for tool-carrying traffic. Enabling it is a deliberate acceptance of what trusted actually measures, not a safety feature: spike 049 measured that a seeder self-grants trusted with about 20 one-token settles in about 2 minutes of wall time at the settle rate limit, and the attack is net-free — crossing the reputation threshold auto-releases the 100 escrowed credits straight back to the seeder (0.1% of one 100k welcome grant, refunded). trusted is escrow-release probation (D-08), not an integrity attestation, and it cannot authorize code execution on your machine. This is exactly why a trusted seeder is refused trusted_but_not_sufficient (§6 table) while the opt-in is off — the refusal is not a judgment that the seeder "looks shady", it is a statement that the flag alone was never designed to carry this weight. If you enable the opt-in anyway, you are accepting that cost with open eyes, not being told it is safe.

What the proxy tells your client. Every completion routed to a non-owned node carries X-RentMyGPU-Require-Approval: true on the response — present only when the serving node is NOT your own (false only when your own node served it, meaning no untrusted producer produced that completion; the header is absent entirely on plain chat). false is a statement about who produced the tokens, never a recommendation to auto-approve. On the trusted opt-in path only, the response additionally carries X-RentMyGPU-Routing-Warning with the cost warning above. Two structured log events fire on every such completion, never once per process or once per peer: proxy.non_own_node_route (the allowlist path) and proxy.trusted_route (the trusted opt-in path). The warning text is never written into the completion content or into an SSE delta — it would land where an agentic client parses tool calls from, corrupting the exact output you are billed for.

Selection order. Among eligible candidates, selection is tiered and deterministic, never random across tiers: your own node first, then allowlisted peers in allowlist order, then trusted-opt-in peers last. The most user-controlled identity always wins.

Production note. The deployed tracker at https://rmg-tracker-oracle.yaarbal.app is behind this revision. Until it is redeployed from a build that includes this phase's commits, its /peers, /model/<name>/peers, and GET /credits/<peer_id> responses carry no trusted key at all — not false, simply absent, which every consumer treats identically to false (fail closed). That means --allow-trusted-seeders makes no production peer eligible today, and trusted_but_not_sufficient cannot be observed against the production tracker until the redeploy lands. The allowlist path is unaffected and works against any tracker, deployed or not. RENTMYGPU_PROXY_OWN_IDENTITY_PATH exists for operators whose earn identity lives at a non-default path — pointing it at an absent file makes own_peer_id resolve to None, useful for running the proxy as a deliberate non-owner during testing.

Malformed tool calls are not errors

A turn that contains any tool call the parser refuses — most often a small model hallucinating a plausible name the client never declared (goose 1.49 declares no file-read tool at all, so Qwen3.5-9B reaching for read is expected) — still fails whole: no tool_calls delta and no tool_calls message field is emitted, and the well-formed subset is withheld too (a partial call set in an agentic loop is the silent difference spike 048 forbids). A call to a function you did not declare is never handed to your client, under any input.

What changed (debug session goose-unknown-function-fail-whole): the turn no longer ends as an in-stream server_error frame / HTTP 502. goose, and the OpenAI SDKs, treat any in-stream error object as a fatal provider error regardless of its type — goose retried the identical request three times and then aborted the session, discarding the prose the model had already produced. Now the turn ends as a normal completion:

"choices": [{"index": 0, "delta": {}, "finish_reason": "content_filter"}],
"usage": {"prompt_tokens": 0, "completion_tokens": 24, "total_tokens": 24},
"rentmygpu": {
  "tool_call_rejection": {
    "code": "malformed_tool_call",
    "message": "the model called `read`, which is not a declared tool. No tool call was emitted for this turn (fail-whole); the prose is intact. Declared tools: analyze, shell, write.",
    "malformed": [{"reason": "unknown_function", "name": "read"}],
    "declared_tools": ["analyze", "shell", "write"]
  }
}

finish_reason is deliberately never "stop" here: a client that ignores the rentmygpu extension must still be able to tell a refused call from a model that simply emitted none. Extra top-level keys are ignored by every OpenAI-compatible client we tested; the choices shape is untouched.

The proxy does not re-prompt the model on your behalf — it is stateless per request, a silent second generation would double your bill, and your conversation history would no longer match what the model actually saw. An agent that wants automatic self-correction can append a user message built from declared_tools ("read is not a tool; use shell") and re-request. The tokens of the refused turn are billed as usual (they were generated); the event is logged as proxy.tool_call_rejected.

7. Billing

Every completion settles exactly once, through the same /credits/settle path yaarbal spend already uses, signed by your spend identity — there is no second settle implementation anywhere in this codebase.

Own-node use is not free. Even when the proxy routes a completion to a GPU you yourself own, your spend wallet is debited tokens × weight (a tier table by declared parameter size — e.g. a 9B model is 5 credits/token) plus the flat per-settle network tax, and your earn wallet is credited the tokens×weight amount minus that network tax and any escrow hold. This is exactly how the network already prices every other spender; the proxy gets no exemption for talking to your own hardware.

Billed tokens are always engine tokens — the count the seeder itself reports — never SSE chunk counts (which can under-count by up to 50x on a chunked transport, spike 030). Billing is clamped to the frames the proxy actually decrypted, so a cancelled or dropped stream bills only what was delivered; zero frames delivered bills nothing.

Insufficient credits are refused with a 402 insufficient_credits before any prompt byte is rendered or sent — never a stream that starts and gets truncated. When a request IS affordable, max_tokens is silently clamped downward to what your balance can afford; the clamped value is reported in the X-RentMyGPU-Max-Tokens response header, and a completion that hits that clamp reports finish_reason: "length" rather than "stop". usage.prompt_tokens is always 0 in every response — prompt tokens bill zero credits on this network.

The unsettled record. ~/.rentmygpu/proxy-unsettled.jsonl is a durable, append-only JSONL log the proxy writes to before it retries a failed settle, so a witnessed debit is never lost even if the process crashes mid-retry. Each line has a status of pending (about to retry), settled (confirmed), or failed (retries exhausted, the tracker never confirmed this debit). On startup, yaarbal proxy scans this file and logs a proxy.unsettled_records warning if any request_id's latest status is pending or failed — a failed line means the proxy believes it owes a debit the tracker never acknowledged; there is no automatic reconciliation, the operator inspects the line by hand.

State the residual plainly: every settle retry re-mints a fresh signed envelope (new nonce, new timestamp) over the same request_id, relying on the tracker's in-memory settle dedup (credits_routes.py's _settle_dedup) for idempotency — there is no UNIQUE constraint on settlements.request_id at the database layer. The whole retry sequence is bounded by RENTMYGPU_PROXY_SETTLE_MAX_SPAN (default 30s), deliberately kept below the tracker's 35s --graceful-timeout (deploy/Dockerfile), so that a tracker restart landing inside an in-flight retry window is unlikely — but not impossible. If it happens, the same completion could be settled twice: real money, a small window, disclosed here rather than hidden. A database-level UNIQUE constraint on settlements.request_id would close this structurally; it is a separate future item, out of this phase's scope.

8. Environment knobs

Every RENTMYGPU_PROXY_* name declared in rentmygpu/proxy/config.py:

Variable Default Meaning
RENTMYGPU_PROXY_PORT 7100 Loopback HTTP port yaarbal proxy binds (also --port's default).
RENTMYGPU_PROXY_DEFAULT_MAX_TOKENS 2048 max_tokens used when a request body omits it.
RENTMYGPU_PROXY_MAX_TOKENS_CAP 8192 Hard ceiling max_tokens is clamped to regardless of what the client requests.
RENTMYGPU_PROXY_MAX_PROMPT_CHARS 32000 Floor and unadvertised-peer fallback for the rendered-prompt character cap; the real cap is derived per request from the selected peer's advertised n_ctx (Phase 26 CTX-02/CTX-03). Restates the seeder's own _MAX_PROMPT_CHARS floor; Phase 26 raises both together. History is trimmed toward a peer-derived budget before this cap is ever checked (§6).
RENTMYGPU_PROXY_INFER_TIMEOUT 900.0 Wall-clock timeout (seconds) for the seeder /infer SSE round trip.
RENTMYGPU_PROXY_HTTP_TIMEOUT 30.0 Wall-clock timeout (seconds) for ordinary (non-streaming) calls to the seeder/tracker: discovery, /status, /credits/settle.
RENTMYGPU_PROXY_DISCOVERY_TTL 10.0 Per-model peer-list cache TTL (seconds) — avoids a discovery round trip on every completion.
RENTMYGPU_PROXY_ENC_KEY_MAX_AGE 240.0 Max age (seconds) of a cached seeder encryption-key envelope before a proactive re-fetch, kept comfortably below the seeder's own key-rotation grace window.
RENTMYGPU_PROXY_SETTLE_RETRIES 3 Max settle retry attempts after the first failed attempt.
RENTMYGPU_PROXY_SETTLE_BACKOFF 1.0 Base backoff (seconds) between settle retries.
RENTMYGPU_PROXY_SETTLE_MAX_SPAN 30.0 Total wall-clock budget (seconds) for the whole settle retry sequence — kept below the tracker's 35s --graceful-timeout (§7).
RENTMYGPU_PROXY_THINKING false Process-level default for enable_thinking on every request lacking a per-request override (§5).
RENTMYGPU_PROXY_KEY_PATH ~/.rentmygpu/proxy-key Override the local bearer-key file path.
RENTMYGPU_PROXY_ALLOWLIST_PATH ~/.rentmygpu/agentic-allowlist.json Override the agentic allowlist file path — see "Widening beyond your own node" (§6).
RENTMYGPU_PROXY_ALLOW_TRUSTED false Process-level default for --allow-trusted-seeders — see "Widening beyond your own node" (§6).
RENTMYGPU_PROXY_OWN_IDENTITY_PATH ~/.rentmygpu/identity.key Override the EARN identity the routing gate reads read-only to recognise your own node.

9. Limitations (this phase)

10. Disclosures