<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Wokey Engineering]]></title><description><![CDATA[Wokey Engineering]]></description><link>https://wokey.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 12 Nov 2026 08:57:22 GMT</lastBuildDate><atom:link href="https://wokey.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Claude Code and the Anthropic SDKs Behind a Custom Base URL: Setup, Checks, and Gotchas]]></title><description><![CDATA[ANTHROPIC_BASE_URL, credential headers, a no-key echo server, and why the model field is not proof]]></description><link>https://wokey.hashnode.dev/claude-code-and-the-anthropic-sdks-behind-a-custom-base-url-setup-checks-and-gotchas</link><guid isPermaLink="true">https://wokey.hashnode.dev/claude-code-and-the-anthropic-sdks-behind-a-custom-base-url-setup-checks-and-gotchas</guid><category><![CDATA[claude-code]]></category><category><![CDATA[#anthropic]]></category><category><![CDATA[llm]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Wokey]]></dc:creator><pubDate>Wed, 11 Nov 2026 06:06:42 GMT</pubDate><content:encoded><![CDATA[<p>Claude Code and Anthropic's own SDKs talk to <code>https://api.anthropic.com</code> by default, but all of them let you change the base URL. That is how teams route traffic through an internal LLM gateway, a proxy, or a third-party Anthropic-compatible API. The switch itself is two environment variables. The part that eats time is everything around it: which credential variable to use, why a correct-looking URL returns 404, and how to tell what actually answered.</p>
<p>This post walks through the setup for Claude Code, the Python SDK, and the TypeScript SDK, then shows a tiny local "echo" server you can use to see exactly what each client sends, without an API key. The examples use <a href="https://wokey.ai">Wokey</a> as the gateway, but every step works the same with any Anthropic-compatible endpoint.</p>
<blockquote>
<p>Disclosure: this post is published by the Wokey team. All commands were run on 2026-11-11 with Claude Code 2.1.322, <code>anthropic</code> (Python) 2.8.0 and <code>@anthropic-ai/sdk</code> (TypeScript) 1.8.0. Calls that need a real gateway key were not run with one; where that matters, it is called out.</p>
</blockquote>
<h2>What "Anthropic-compatible" means in practice</h2>
<p>An Anthropic-compatible gateway exposes the Messages API at <code>POST /v1/messages</code>, accepts the same JSON body (<code>model</code>, <code>max_tokens</code>, <code>messages</code>, optional <code>system</code>, <code>tools</code>, <code>stream</code>, and so on), and returns the same response and Server-Sent Events (SSE) stream shapes.</p>
<p>The client builds the full URL by appending <code>/v1/messages</code> to whatever base URL you give it. Keep that in mind; it explains the most common failure later on.</p>
<h2>Step 1: Find model IDs the gateway actually serves</h2>
<p>Claude Code picks a model ID for you unless you set one, and a gateway only accepts the IDs it knows. So start by listing them. Many gateways expose an OpenAI-style <code>GET /v1/models</code>; on Wokey it is public, so this runs without a key:</p>
<pre><code class="language-python"># list_models.py - list Claude-family model IDs a gateway exposes
import json, sys, urllib.request

url = sys.argv[1] if len(sys.argv) &gt; 1 else "https://api.wokey.ai/v1/models"
with urllib.request.urlopen(url, timeout=15) as resp:
    models = json.load(resp)["data"]

for m in sorted(models, key=lambda m: m["id"]):
    if "claude" in m["id"]:
        print(f'{m["id"]:&lt;28} context={m.get("context_length")} max_out={m.get("max_completion_tokens")}')
</code></pre>
<pre><code class="language-bash">python3 list_models.py
</code></pre>
<p>Output on 2026-11-11 (trimmed):</p>
<pre><code class="language-text">claude-haiku-4-5             context=200000 max_out=64000
claude-haiku-5-5             context=1000000 max_out=128000
claude-opus-5-5              context=1000000 max_out=128000
claude-sonnet-4-6            context=1000000 max_out=128000
claude-sonnet-5              context=1000000 max_out=128000
claude-sonnet-5-5            context=1000000 max_out=128000
</code></pre>
<p>The same script accepts another feed URL, for example <code>python3 list_models.py https://openrouter.ai/api/v1/models</code>. Field names differ between providers (OpenRouter returns IDs like <code>anthropic/claude-sonnet-5</code> and has no top-level <code>max_completion_tokens</code>), so treat the output as a starting point and always copy IDs exactly.</p>
<h2>Step 2: Point Claude Code at the gateway</h2>
<p>Claude Code reads two variables: <code>ANTHROPIC_BASE_URL</code> and a credential. According to the <a href="https://code.claude.com/docs/en/env-vars">Claude Code environment variable reference</a>, <code>ANTHROPIC_AUTH_TOKEN</code> is sent as <code>Authorization: Bearer &lt;value&gt;</code> and <code>ANTHROPIC_API_KEY</code> is sent as an <code>X-Api-Key</code> header. Use whichever header your gateway reads; Wokey's setup guide uses <code>ANTHROPIC_AUTH_TOKEN</code>.</p>
<pre><code class="language-bash">export ANTHROPIC_BASE_URL="https://api.wokey.ai"
export ANTHROPIC_AUTH_TOKEN="YOUR_API_KEY"
export ANTHROPIC_MODEL="claude-sonnet-5-5"   # optional: pin an ID from Step 1
claude
</code></pre>
<p>On Windows PowerShell, the same thing is <code>$env:ANTHROPIC_BASE_URL = "https://api.wokey.ai"</code> and so on; values set this way last for that terminal only.</p>
<p>To persist the setup, put the variables in the <code>env</code> block of <code>~/.claude/settings.json</code>:</p>
<pre><code class="language-json">{
  "env": {
    "ANTHROPIC_BASE_URL": "https://api.wokey.ai",
    "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY",
    "ANTHROPIC_MODEL": "claude-sonnet-5-5"
  }
}
</code></pre>
<p>Three details from Anthropic's <a href="https://code.claude.com/docs/en/llm-gateway-connect">gateway connection guide</a>:</p>
<ol>
<li>When both a shell export and a settings-file <code>env</code> block set the same variable, the settings-file value wins.</li>
<li>Never put the credential in a project's <code>.claude/settings.json</code>; that file is meant to be committed. Use <code>~/.claude/settings.json</code> or a git-ignored <code>.claude/settings.local.json</code>.</li>
<li>Inside Claude Code, <code>/status</code> shows an <code>Anthropic base URL</code> line and which credential source is active. If the base URL line is missing, the variable never reached the session.</li>
</ol>
<p>If you also want the <code>sonnet</code>, <code>opus</code> and <code>haiku</code> aliases to resolve to IDs your gateway serves, Claude Code has <code>ANTHROPIC_DEFAULT_SONNET_MODEL</code>, <code>ANTHROPIC_DEFAULT_OPUS_MODEL</code> and <code>ANTHROPIC_DEFAULT_HAIKU_MODEL</code>. The Haiku one is also used for background tasks.</p>
<p>One scope note: Anthropic's <a href="https://code.claude.com/docs/en/llm-gateway">LLM gateway page</a> says it "doesn't support routing Claude Code to non-Claude models through any gateway". Some gateways accept other model families on their Messages endpoint, but for Claude Code, stick to Claude model IDs.</p>
<h2>Step 3: Check the URL and key with curl before blaming the client</h2>
<p>A single request rules out most problems:</p>
<pre><code class="language-bash">curl -sS -w '\n%{http_code}\n' -X POST "$ANTHROPIC_BASE_URL/v1/messages" \
  -H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{"model": "claude-sonnet-5-5", "max_tokens": 1, "messages": [{"role": "user", "content": "."}]}'
</code></pre>
<p>With a real key, a <code>200</code> and a body starting with <code>{"id":"msg_</code> means the base URL and credential work (not run here; no key). What I could run is the failure path. With a deliberately invalid key, the live endpoint returned an Anthropic-shaped error:</p>
<pre><code class="language-text">{"type":"error","error":{"code":"invalid_api_key","message":"Malformed API key. Wokey API keys start with sk-; ...","type":"authentication_error"}}
401
</code></pre>
<p>A <code>401</code> means the request reached the gateway and the route exists. A <code>404</code> means the path is wrong, which brings us to the main gotcha.</p>
<h2>Step 4: The same check from the Anthropic SDKs</h2>
<p>Both SDKs take a base URL and, like Claude Code, append <code>/v1/messages</code> themselves. Both also read <code>ANTHROPIC_BASE_URL</code> from the environment if you don't pass one. The script below sends one request and prints what came back, including response headers:</p>
<pre><code class="language-python"># probe.py - send one Messages request and show which model answered
import os
from anthropic import Anthropic

client = Anthropic(
    base_url=os.environ.get("ANTHROPIC_BASE_URL", "https://api.wokey.ai"),
    api_key=os.environ["GATEWAY_API_KEY"],
)

raw = client.messages.with_raw_response.create(
    model=os.environ.get("MODEL", "claude-sonnet-5-5"),
    max_tokens=64,
    messages=[{"role": "user", "content": "Reply with one short sentence."}],
)
msg = raw.parse()

print("requested model :", os.environ.get("MODEL", "claude-sonnet-5-5"))
print("response model  :", msg.model)
print("gateway req id  :", raw.headers.get("x-wokey-request-id"))  # Wokey-specific header
print("upstream req id :", raw.headers.get("request-id"))
print("tokens in/out   :", msg.usage.input_tokens, "/", msg.usage.output_tokens)
print("text            :", msg.content[0].text)
</code></pre>
<p>The TypeScript version uses <code>.withResponse()</code> to get the raw <code>Response</code>:</p>
<pre><code class="language-typescript">// probe.mjs - same check with the TypeScript/JavaScript SDK
import Anthropic from '@anthropic-ai/sdk'

const client = new Anthropic({
  baseURL: process.env.ANTHROPIC_BASE_URL ?? 'https://api.wokey.ai',
  apiKey: process.env.GATEWAY_API_KEY,
})

const { data: msg, response } = await client.messages
  .create({
    model: process.env.MODEL ?? 'claude-sonnet-5-5',
    max_tokens: 64,
    messages: [{ role: 'user', content: 'Reply with one short sentence.' }],
  })
  .withResponse()

console.log('response model :', msg.model)
console.log('gateway req id :', response.headers.get('x-wokey-request-id')) // Wokey-specific
console.log('upstream req id:', response.headers.get('request-id'))
console.log('text           :', msg.content[0].text)
</code></pre>
<p><code>request-id</code> is the header Anthropic documents on every API response (<a href="https://platform.claude.com/docs/en/api/errors">errors reference</a>). <a href="https://wokey.ai/docs/messages">Wokey's Messages reference</a> says a successful response carries both its own <code>x-wokey-request-id</code> and the upstream <code>request-id</code>; I could only confirm that against the local server below, not with a live key. Against the real endpoint with an invalid key, both scripts raise an <code>AuthenticationError</code> (401) with the JSON shown in Step 3.</p>
<h2>Step 5: See what your client really sends, with no key at all</h2>
<p>When a client misbehaves, it helps to stop guessing and look at the raw request. This roughly 30-line server (standard library only) logs the method, path, credential header and model, then returns a minimal Messages response:</p>
<pre><code class="language-python"># echo_server.py - a fake Anthropic-style endpoint that logs what clients send
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        print(f"{self.command} {self.path}")
        for h in ("x-api-key", "authorization"):  # credentials: print a prefix only
            if self.headers.get(h):
                print(f"  {h}: {self.headers[h][:12]}...")
        print(f"  anthropic-version: {self.headers.get('anthropic-version')}")
        print(f"  model={body.get('model')} max_tokens={body.get('max_tokens')}")
        if self.path.split("?")[0] != "/v1/messages":
            self.send_response(404); self.end_headers(); return
        out = json.dumps({
            "id": "msg_local", "type": "message", "role": "assistant",
            "model": body.get("model"), "stop_reason": "end_turn",
            "stop_sequence": None,
            "content": [{"type": "text", "text": "hello from the echo server"}],
            "usage": {"input_tokens": 1, "output_tokens": 1},
        }).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("x-wokey-request-id", "local-test-123")
        self.send_header("Content-Length", str(len(out)))
        self.end_headers()
        self.wfile.write(out)

    def log_message(self, *args):  # silence default access log
        pass

HTTPServer(("127.0.0.1", 8787), Handler).serve_forever()
</code></pre>
<p>Run it in one terminal, then point clients at it in another:</p>
<pre><code class="language-bash">python3 echo_server.py
# second terminal
ANTHROPIC_BASE_URL=http://127.0.0.1:8787 GATEWAY_API_KEY=sk-test-0000 python3 probe.py
ANTHROPIC_BASE_URL=http://127.0.0.1:8787 ANTHROPIC_AUTH_TOKEN=sk-test-0000 \
  ANTHROPIC_MODEL=claude-sonnet-5-5 claude -p "say pong"
</code></pre>
<p>What the log showed in my run:</p>
<ul>
<li><strong>Python and TypeScript SDKs:</strong> <code>POST /v1/messages</code> with <code>Authorization: Bearer ...</code>, even though the key was passed as <code>api_key</code>/<code>apiKey</code>. That matches Wokey's note that current Anthropic SDKs send the key as a Bearer token. The SDKs sent <code>anthropic-version: 2026-10-21</code>.</li>
<li><strong>Claude Code:</strong> <code>POST /v1/messages?beta=true</code> with <code>anthropic-version: 2023-06-01</code>. Because this server never streams, two requests showed up before Claude Code printed the echo text. A gateway that rejects unknown query strings would break here, so the <code>?beta=true</code> detail is worth knowing.</li>
<li><strong>Claude Code with no model set</strong> asked for <code>claude-opus-5-5</code> on this version. Defaults change between releases and account types, which is one more reason to pin <code>ANTHROPIC_MODEL</code>.</li>
</ul>
<h2>Gotchas, in the order you'll probably hit them</h2>
<ol>
<li><strong>A trailing <code>/v1</code> in the base URL.</strong> With <code>ANTHROPIC_BASE_URL=http://127.0.0.1:8787/v1</code>, every client requested <code>/v1/v1/messages</code>. The SDKs raised <code>NotFoundError: 404</code>. Claude Code reported: <em>"There's an issue with the selected model (claude-sonnet-5-5). It may not exist or you may not have access to it."</em> The model was fine; the path wasn't. The live Wokey endpoint returns <code>Route POST:/v1/v1/messages not found</code> for the same mistake. OpenAI-style clients are usually configured with <code>/v1</code> in the base URL; Anthropic clients are not.</li>
<li><strong>Two credentials at once.</strong> With both <code>ANTHROPIC_AUTH_TOKEN</code> and <code>ANTHROPIC_API_KEY</code> set, Claude Code sent <em>both</em> an <code>x-api-key</code> and an <code>Authorization</code> header. Wokey's error docs list headers that disagree as a cause of 401, and Anthropic's guide warns about auth conflicts. Unset the one you don't use. The Python SDK, given both variables, warned and used <code>ANTHROPIC_API_KEY</code>.</li>
<li><strong>A global <code>ANTHROPIC_BASE_URL</code> reroutes every SDK script.</strong> Exporting it in <code>~/.bashrc</code> for Claude Code also changes the destination of any Python or Node code that calls <code>Anthropic()</code> without arguments. If you only want Claude Code rerouted, setting the variables in <code>~/.claude/settings.json</code> keeps them out of your normal shell sessions.</li>
<li><strong>Features tied to a claude.ai login.</strong> Anthropic's guide notes that Remote Control is disabled when <code>ANTHROPIC_BASE_URL</code> points at a non-Anthropic host, and voice dictation is unavailable while a gateway credential is active.</li>
<li><strong>Usage numbers aren't always comparable.</strong> Wokey's Messages reference notes that for Claude models <code>input_tokens</code> excludes cached tokens, as Anthropic's API does, while for converted non-Claude models it includes them. Check your gateway's docs before building cost dashboards on <code>usage</code>.</li>
</ol>
<h2>What the <code>model</code> field tells you, and what it doesn't</h2>
<p>The echo server above returned whatever <code>model</code> the client asked for, and every client accepted it. That's the point: the <code>model</code> field in a response is a string the server writes. It's useful for catching configuration mistakes (an alias resolving to an unexpected ID, a client ignoring your pin), but it isn't evidence of which model produced the text. Wokey's own verifiable-API feature page puts it plainly: "A relay can put anything in the model field of its JSON."</p>
<p>Request IDs help with support tickets, not with proof. Some gateways, Wokey included, can attach a signed attestation produced in an AWS Nitro Enclave on certain raw passthrough routes. That shows the response bytes came from the upstream provider's TLS endpoint; it does not show which model the provider ran internally.</p>
<h2>Takeaways</h2>
<ul>
<li>Base URL without <code>/v1</code> for Anthropic clients; the client appends <code>/v1/messages</code>.</li>
<li>Pick one credential variable: <code>ANTHROPIC_AUTH_TOKEN</code> for Bearer, <code>ANTHROPIC_API_KEY</code> for <code>x-api-key</code>. Not both.</li>
<li>Pin <code>ANTHROPIC_MODEL</code> to an ID copied from the gateway's model list.</li>
<li>Test with curl first, then with <code>/status</code>, and keep a local echo server around for anything confusing.</li>
<li>Read the <code>model</code> field as a config check, not as proof.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[What a Coding-Agent Session Really Costs: Estimating Spend and Cache Hits from a /models Pricing Feed]]></title><description><![CDATA[Coding agents like Claude Code, Codex CLI, and opencode don't send "a prompt". Every turn re-sends the system prompt, the tool schemas, and the whole…]]></description><link>https://wokey.hashnode.dev/what-a-coding-agent-session-really-costs-estimating-spend-and-cache-hits-from-a-models-pricing-feed</link><guid isPermaLink="true">https://wokey.hashnode.dev/what-a-coding-agent-session-really-costs-estimating-spend-and-cache-hits-from-a-models-pricing-feed</guid><category><![CDATA[Python]]></category><category><![CDATA[llm]]></category><category><![CDATA[AI]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Wokey]]></dc:creator><pubDate>Wed, 04 Nov 2026 06:08:22 GMT</pubDate><content:encoded><![CDATA[<p>Coding agents like Claude Code, Codex CLI, and opencode don't send "a prompt". Every turn re-sends the system prompt, the tool schemas, and the whole conversation so far. A 40-turn session can easily push millions of input tokens through the API, and what that costs depends far more on prompt caching than on the per-token headline price.</p>
<p>This post builds a small Python script (about 65 lines, standard library only) that reads a public <code>/models</code> pricing feed and estimates what a session costs with and without caching. It works with any feed that uses the OpenRouter-style schema, so you can point it at OpenRouter, at the gateway you already use, or at both.</p>
<blockquote>
<p>Disclosure: this post is published by the Wokey team. The script is vendor-neutral and every number below comes from public pricing feeds fetched on 2026-11-04.</p>
</blockquote>
<h2>The pricing feed format</h2>
<p>OpenRouter's <code>GET https://openrouter.ai/api/v1/models</code> returns one object per model with a <code>pricing</code> block. Prices are <strong>USD per token</strong>, written as decimal strings:</p>
<pre><code class="language-json">"pricing": {
  "prompt": "0.000002",
  "completion": "0.00001",
  "input_cache_read": "0.0000002",
  "input_cache_write": "0.0000025",
  "input_cache_write_1h": "0.000004"
}
</code></pre>
<p>Some other gateways expose the same shape so that OpenAI-compatible tooling can read it. For example <code>GET https://api.wokey.ai/v1/models</code> (no API key needed) returns the same <code>prompt</code> / <code>completion</code> / <code>input_cache_read</code> / <code>input_cache_write</code> fields. Not every model has every field: some models list no <code>input_cache_write</code> because their provider doesn't charge extra to write the cache.</p>
<p>Two things to keep in mind:</p>
<ul>
<li>Use <code>Decimal</code>, not <code>float</code>. Per-token prices like <code>0.00000003</code> are exactly the kind of number floats round badly.</li>
<li><code>input_cache_write</code> is the price for the default (5-minute) cache; <code>input_cache_write_1h</code> is the longer-lived option on Anthropic models. The script below uses the 5-minute price.</li>
</ul>
<h2>How an agent session actually spends tokens</h2>
<p>A simple but honest model of an agent session:</p>
<ol>
<li><strong>Turn 1</strong> sends a fixed prefix (system prompt plus tool definitions, often 10k to 30k tokens for coding agents) and the first user message.</li>
<li><strong>Every later turn</strong> re-sends everything from before (prefix plus all earlier messages and replies) plus some new tokens (your message, tool results).</li>
<li>Each turn produces some output tokens, which become part of the history for the next turn.</li>
</ol>
<p>Without caching, all of that re-sent history is billed at the full input price, every turn. With prompt caching, the part the provider has already seen can be read from cache at a much lower rate, and new tokens are written to the cache so the next turn can read them.</p>
<p>Caching isn't free or automatic everywhere, and the details matter:</p>
<ul>
<li>On Anthropic models, the request must mark cache breakpoints with <code>cache_control</code> (Claude Code does this for you). Per <a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching">Anthropic's prompt caching docs</a>, the default cache lives 5 minutes, refreshed each time it's used, with a 1-hour option at extra cost, and there is a minimum cacheable prompt length per model.</li>
<li>A cache miss doesn't just cost the normal input price; on providers that charge for cache writes, the missed tokens are written again at the write price.</li>
</ul>
<p>So the realistic question isn't "caching on or off", it's "what's my hit rate?"</p>
<h2>The script</h2>
<p>Save this as <code>session_cost.py</code>:</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Estimate what a multi-turn agent session costs, using any
OpenRouter-style /models feed (per-token prices as decimal strings)."""
import argparse
import json
import urllib.request
from decimal import Decimal

def load_pricing(models_url, model_id):
    req = urllib.request.Request(models_url, headers={"User-Agent": "session-cost/0.1"})
    with urllib.request.urlopen(req, timeout=30) as resp:
        data = json.load(resp)["data"]
    for m in data:
        if m["id"] == model_id:
            return {k: Decimal(v) for k, v in m["pricing"].items()
                    if isinstance(v, str)}
    raise SystemExit(f"model {model_id!r} not found in {models_url}")

def session_tokens(turns, prefix, per_turn_in, per_turn_out):
    """Yield (fresh_input, cached_prefix, output) per turn.
    Turn 1 sends the prefix plus the first message. Every later turn
    re-sends everything from before (prefix + history) plus new tokens."""
    history = 0
    for i in range(turns):
        seen = prefix + history if i else 0
        fresh = per_turn_in if i else prefix + per_turn_in
        yield fresh, seen, per_turn_out
        history += per_turn_in + per_turn_out

def cost(p, turns, prefix, per_turn_in, per_turn_out, hit_rate):
    """hit_rate=None means no caching at all."""
    total = Decimal(0)
    for fresh, seen, out in session_tokens(turns, prefix, per_turn_in, per_turn_out):
        if hit_rate is not None and "input_cache_read" in p:
            # a hit reads seen tokens from cache; a miss writes them again.
            # new tokens are written so the next turn can read them.
            write = p.get("input_cache_write", p["prompt"])
            hit = seen * hit_rate
            total += hit * p["input_cache_read"] + (seen - hit + fresh) * write
        else:
            total += (seen + fresh) * p["prompt"]
        total += out * p["completion"]
    return total

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--models-url", required=True)
    ap.add_argument("--model", required=True)
    ap.add_argument("--turns", type=int, default=40)
    ap.add_argument("--prefix", type=int, default=20000, help="system prompt + tool schemas")
    ap.add_argument("--turn-in", type=int, default=3000, help="new input tokens per turn")
    ap.add_argument("--turn-out", type=int, default=800, help="output tokens per turn")
    ap.add_argument("--hit-rate", type=Decimal, default=Decimal("0.95"),
                    help="share of previously seen tokens read from cache")
    a = ap.parse_args()
    p = load_pricing(a.models_url, a.model)
    args = (a.turns, a.prefix, a.turn_in, a.turn_out)
    total_in = sum(f + s for f, s, _ in session_tokens(*args))
    print(f"model: {a.model}")
    print(f"input tokens sent over {a.turns} turns: {total_in:,}")
    rows = [("no caching", None), (f"cache, {a.hit_rate:.0%} hits", a.hit_rate),
            ("cache, 100% hits", Decimal(1))]
    for label, rate in rows:
        print(f"{label + ':':&lt;19} ${cost(p, *args, rate):.4f}")

if __name__ == "__main__":
    main()
</code></pre>
<p>It needs Python 3.8 or newer and nothing outside the standard library. On Windows, run it with <code>py session_cost.py ...</code> and put each command on one line.</p>
<h2>Running it</h2>
<p>With the defaults (40 turns, a 20k-token prefix, 3k new input tokens and 800 output tokens per turn, 95% cache hits) against OpenRouter's feed:</p>
<pre><code class="language-bash">python3 session_cost.py --models-url https://openrouter.ai/api/v1/models \
  --model anthropic/claude-sonnet-5
</code></pre>
<pre><code>model: anthropic/claude-sonnet-5
input tokens sent over 40 turns: 3,884,000
no caching:         $8.0880
cache, 95% hits:    $1.8494
cache, 100% hits:   $1.4188
</code></pre>
<p>Nearly 3.9 million input tokens for a 40-turn session, even though you only typed a few thousand of them. Output (32,000 tokens here) is a small part of the bill; re-sent context is most of it.</p>
<p>Now drop the hit rate to 50%, which is roughly what you get when something keeps breaking the cache:</p>
<pre><code class="language-bash">python3 session_cost.py --models-url https://openrouter.ai/api/v1/models \
  --model anthropic/claude-sonnet-5 --hit-rate 0.5
</code></pre>
<pre><code>no caching:         $8.0880
cache, 50% hits:    $5.7244
cache, 100% hits:   $1.4188
</code></pre>
<p>The same session costs about four times as much as at full hits. That gap is why hit rate deserves as much attention as the price list.</p>
<p>Because the feed format is shared, you can run the same session against another gateway by changing two arguments. Here is the same model on Wokey's public feed (model IDs differ between feeds, so check <code>/models</code> first):</p>
<pre><code class="language-bash">python3 session_cost.py --models-url https://api.wokey.ai/v1/models \
  --model claude-sonnet-5
</code></pre>
<pre><code>model: claude-sonnet-5
input tokens sent over 40 turns: 3,884,000
no caching:         $1.2132
cache, 95% hits:    $0.2774
cache, 100% hits:   $0.2128
</code></pre>
<p>All of these figures are from the feeds as published on 2026-11-04. Prices change, so rerun the script rather than trusting the numbers here; each provider's own models page is the authoritative source.</p>
<h2>What breaks the cache in practice</h2>
<p>Your hit rate is set mostly by your client and your habits, not by the provider. Common causes of misses:</p>
<ul>
<li><strong>Idle time.</strong> If you step away for longer than the cache lifetime (5 minutes by default on Anthropic models), the next turn re-writes the whole prefix.</li>
<li><strong>Changing the prefix.</strong> Editing the system prompt, adding or removing tools, or switching models mid-conversation invalidates everything after the change.</li>
<li><strong>Routing to a different upstream account or endpoint.</strong> Caches belong to the upstream account, so a gateway that spreads one conversation across accounts turns hits into writes.</li>
<li><strong>Per-request data at the top of the prompt.</strong> Anything that changes every request (a timestamp, a request ID) placed before the cached content defeats caching on gateways that cache on the request body.</li>
</ul>
<p>Wokey publishes weekly measured <a href="https://wokey.ai/cache-hit-rate">cache hit rates by client and model</a>, including a breakdown of why misses happen, which is a useful reference for what "normal" looks like for Claude Code, Codex, and others.</p>
<h2>Limitations of this estimate</h2>
<p>Be clear about what the script does and doesn't model:</p>
<ul>
<li>It assumes a steady session: the same new input and output per turn. Real sessions are lumpy (a big file read, a long tool output), so treat the result as an order-of-magnitude estimate.</li>
<li>It ignores context compaction, which agents use to shrink history on long sessions.</li>
<li>It uses the 5-minute cache write price and ignores <code>input_cache_write_1h</code>.</li>
<li>It ignores long-context price tiers. OpenRouter lists these in an <code>overrides</code> array on some models (for example, higher prices above 200,000 prompt tokens); the script doesn't read it.</li>
<li>Models with no <code>input_cache_read</code> price are treated as uncached.</li>
</ul>
<h2>Takeaways</h2>
<ul>
<li>For agents, input tokens from re-sent context dominate the bill, not output.</li>
<li>Cache hit rate moves cost by multiples. Estimate with a realistic rate (try 0.5, 0.8, and 0.95), not 100%.</li>
<li>Read prices from the provider's <code>/models</code> feed as <code>Decimal</code>, and rerun estimates when prices change.</li>
<li>When comparing gateways, compare the same session profile at the same hit rate, and check the hit rate you actually get from your usage records.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Verify LLM API Responses Offline: Checking an AWS Nitro Proof on Your Own Machine]]></title><description><![CDATA[A hands-on guide to capturing an LLM response together with its enclave-signed proof, checking it locally, and seeing exactly what that proof covers and…]]></description><link>https://wokey.hashnode.dev/verify-llm-api-responses-offline-checking-an-aws-nitro-proof-on-your-own-machine</link><guid isPermaLink="true">https://wokey.hashnode.dev/verify-llm-api-responses-offline-checking-an-aws-nitro-proof-on-your-own-machine</guid><category><![CDATA[llm]]></category><category><![CDATA[Security]]></category><category><![CDATA[AI]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Wokey]]></dc:creator><pubDate>Fri, 23 Oct 2026 06:02:32 GMT</pubDate><content:encoded><![CDATA[<p>If you call Claude or GPT through a relay or aggregator, you are trusting that relay on every request. It can put any string in the <code>model</code> field of its JSON, and a vague answer could come from a cheaper model or just a bad day. You can't tell those apart by reading the output.</p>
<p>A different approach is to have the relay's egress run inside a <strong>trusted execution environment</strong> that signs what it actually received from the upstream API. Then you check that signature yourself, offline, without trusting the relay.</p>
<p><em>Disclosure: this post is published by the Wokey team.</em></p>
<p>This tutorial walks through one open-source implementation of that idea, <a href="https://github.com/focuxdot/proof-of-observation">proof-of-observation</a> (MIT/Apache-2.0), as deployed by the <a href="https://wokey.ai">Wokey</a> API gateway. We'll:</p>
<ol>
<li>point an existing OpenAI / OpenRouter-style client at a new <code>base_url</code>,</li>
<li>capture a streaming response that carries a proof,</li>
<li>check it locally with a short Python script and then with the official verifier,</li>
<li>be precise about what a passing check does and does <strong>not</strong> tell you.</li>
</ol>
<h2>How the proof is produced</h2>
<p>Per the repo README and the protocol spec (<code>docs/proof-of-observation-protocol-v1.md</code>), for each request an AWS Nitro Enclave:</p>
<ol>
<li><strong>terminates the upstream TLS connection inside the enclave</strong>, validating the upstream's certificate against a CA bundle compiled into the measured image;</li>
<li><strong>streams the response back while hashing it</strong> (no full-body buffering);</li>
<li><strong>signs a</strong> <code>tee-exchange-v2</code> <strong>statement</strong> with an Ed25519 key that is generated at boot and never leaves the enclave. The statement covers <code>nonce</code>, <code>upstream-host</code>, <code>upstream-path</code>, <code>http-method</code>, <code>http-status</code>, <code>resp-content-type</code>, <code>request-body-sha256</code> and <code>response-body-sha256</code>;</li>
<li><strong>attaches a Nitro (NSM) attestation</strong> that binds that signing key and the enclave image measurement (<strong>PCR0</strong>) to the AWS Nitro root certificate.</li>
</ol>
<p>On your side, you check that the attestation chains to the AWS root, that PCR0 equals a value reproducible from the public source, that the signing key is the one the attestation binds, and that the bytes you received hash to what was signed.</p>
<h2>What a green result proves, and what it doesn't</h2>
<p>Read this before you write any code.</p>
<p><strong>A passing proof shows:</strong> an enclave running the published, reproducibly built image fetched these exact response bytes over TLS from the upstream host named in the signed statement (for example <code>api.anthropic.com</code>), and nobody changed a byte after that, the relay included.</p>
<p><strong>It does not show:</strong></p>
<ul>
<li><strong>Which model the provider actually ran.</strong> The <code>model</code> field you see sits inside the signed upstream bytes, so the relay can't rewrite it. But it is still the provider's own self-report. In the words of the <a href="https://github.com/kslamph/pi-wokey">pi-wokey</a> README: <em>"It cannot prove that OpenAI or Anthropic served the model you asked for, because neither signs its responses."</em></li>
<li><strong>That your exact request was sent.</strong> The gateway rewrites some requests before they reach the enclave (Responses requests get fields filled in, for example). In that case <code>request-body-sha256</code> won't match your local body. Wokey's own docs say Responses proofs "currently prove the response only", and pi-wokey records request binding as unavailable on both proven routes.</li>
<li><strong>Confidentiality.</strong> This is an integrity guarantee. The relay still handles plaintext in transit.</li>
<li><strong>That all of your traffic was attested.</strong> Proofs are per exchange. A response without a proof is simply unproven (spec Â§10.5).</li>
<li><strong>Freshness across byte-identical retries.</strong> Wokey's docs note that the verifier cannot tell an old response being replayed apart from a new one when the requests are byte-identical.</li>
</ul>
<p>You also still trust AWS (the Nitro root and its attestation service), the TEE's isolation, and the published enclave source. A reproducible build shows the running code <em>is</em> that source, not that the source is bug-free (spec Â§10.7).</p>
<p>So the honest summary is that this removes the relay from your trust chain. It doesn't remove the model provider.</p>
<h2>Step 1: Switch your client's base URL</h2>
<p>Wokey's docs list <code>https://api.wokey.ai</code> as the base URL. The gateway accepts paths with or without <code>/v1</code>, so OpenAI SDKs can use <code>https://api.wokey.ai/v1</code>. Endpoints: <code>POST /v1/chat/completions</code>, <code>POST /v1/responses</code>, <code>POST /v1/messages</code>, <code>GET /v1/models</code>.</p>
<p>If you're coming from OpenRouter (<code>https://openrouter.ai/api/v1</code>), the change is the <code>base_url</code>, the key, and the model ID. Wokey uses bare IDs like <code>claude-sonnet-5</code> instead of <code>anthropic/...</code>-style slugs.</p>
<pre><code class="language-python"># pip install openai
from openai import OpenAI

client = OpenAI(
    base_url="https://api.wokey.ai/v1",   # was https://openrouter.ai/api/v1
    api_key="YOUR_WOKEY_KEY",
)

resp = client.chat.completions.create(
    model="gpt-6-sol",
    messages=[{"role": "user", "content": "Hello"}],
)
print(resp.choices[0].message.content)
</code></pre>
<p>Node is the same idea:</p>
<pre><code class="language-js">// npm install openai
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.wokey.ai/v1",
  apiKey: process.env.WOKEY_API_KEY,
});

const resp = await client.chat.completions.create({
  model: "gpt-6-sol",
  messages: [{ role: "user", content: "Hello" }],
});
console.log(resp.choices[0].message.content);
</code></pre>
<p>The live model list is public:</p>
<pre><code class="language-bash">curl -s https://api.wokey.ai/v1/models | python3 -c \
  "import json,sys; print([m['id'] for m in json.load(sys.stdin)['data']])"
</code></pre>
<p><strong>Important:</strong> a Chat Completions response like the one above carries <strong>no proof</strong>. Proofs only exist on raw passthrough routes, where the gateway forwards the upstream bytes untouched:</p>
<table>
<thead>
<tr>
<th>Request</th>
<th>Proof delivery</th>
</tr>
</thead>
<tbody><tr>
<td>Anthropic Messages, streaming (<code>/v1/messages</code>, <code>stream: true</code>)</td>
<td>trailing SSE <code>event: tee.proof</code></td>
</tr>
<tr>
<td>OpenAI Responses, streaming (<code>/v1/responses</code>, official Codex routes)</td>
<td>trailing SSE <code>event: tee.proof</code></td>
</tr>
<tr>
<td>Non-streaming Messages / Responses</td>
<td>send <code>x-wokey-tee-proof-mode: multipart</code> and get <code>multipart/mixed</code> (raw bytes, then proof)</td>
</tr>
</tbody></table>
<p>Converted responses (such as Claude through Chat Completions), image generation, and requests that fell back while the enclave was unavailable carry no proof. If you ask for a multipart proof on a route that can't pass through raw bytes, you get HTTP 422 <code>tee_proof_multipart_requires_raw_passthrough</code>, not a fake proof.</p>
<p>One detail worth knowing: pi-wokey pins the signed upstream for the GPT Responses route as <code>chatgpt.com</code> + <code>/backend-api/codex/responses</code> (OpenAI's Codex backend), not <code>api.openai.com</code>. The Claude route is pinned to <code>api.anthropic.com</code> + <code>/v1/messages</code>. Check the <code>upstream_host</code> and <code>upstream_path</code> in each proof yourself rather than assuming.</p>
<p>You also need to turn on <strong>"Official verification"</strong> for your API key on Wokey's API page. Without it, responses don't carry a proof.</p>
<h2>Step 2: Capture a streaming response with its proof</h2>
<p>The verifier needs the exact bytes, so save them with <code>curl -o</code>. On Windows, use <code>curl.exe -o</code>. PowerShell <code>&gt;</code> redirection or re-saving in Notepad changes line endings or encoding.</p>
<pre><code class="language-bash">curl -sN https://api.wokey.ai/v1/messages \
  -H "x-api-key: $WOKEY_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-sonnet-5","max_tokens":256,"stream":true,"messages":[{"role":"user","content":"Hello"}]}' \
  -o captured-response
</code></pre>
<p>The file should end with something like:</p>
<pre><code class="language-text">event: message_stop
data: {"type":"message_stop"}

event: tee.proof
data: {"v":2,"alg":"ed25519","public_key":"...","nonce":"...","upstream_host":"api.anthropic.com","upstream_path":"/v1/messages","http_method":"POST","http_status":200,"resp_content_type":"text/event-stream","request_body_sha256":"...","response_body_sha256":"...","signature":"...","attestation":"...","pcr0":"..."}
</code></pre>
<p>If there's no <code>tee.proof</code> at the end, treat the response as unproven.</p>
<blockquote>
<p>Per Wokey's docs, the proof arrives as a final <code>tee.proof</code> event at the end of the stream when Official verification is enabled on your key. If your capture doesn't end with one, check that setting first.</p>
</blockquote>
<h2>Step 3: A quick sanity check in Python</h2>
<p>Before running the full verifier, it helps to see the core idea with nothing but <code>hashlib</code>. Everything before the final <code>event: tee.proof</code> record is the upstream body, and its SHA-256 must equal <code>response_body_sha256</code>.</p>
<pre><code class="language-python"># check_proof.py: hash check only, NOT a full verification
import hashlib, json, sys

MARKER = b"event: tee.proof\n"

raw = open(sys.argv[1], "rb").read()
idx = raw.rfind(MARKER)
if idx &lt; 0:
    sys.exit("No tee.proof event found: treat this response as unproven.")

body = raw[:idx]
data_line = raw[idx:].decode("utf-8").split("\n")[1]
proof = json.loads(data_line[len("data: "):])

for k in ("upstream_host", "upstream_path", "http_status", "pcr0"):
    print(f"{k:16}: {proof[k]}")

# The model as the upstream reported it, inside the hashed bytes
for line in body.decode("utf-8").split("\n"):
    if line.startswith("data:") and '"message_start"' in line:
        print(f"{'reported model':16}: {json.loads(line[5:])['message']['model']}")
        break

ok = hashlib.sha256(body).hexdigest() == proof["response_body_sha256"]
print("body hash matches signed hash" if ok else "MISMATCH")
sys.exit(0 if ok else 1)
</code></pre>
<pre><code class="language-bash">python3 check_proof.py captured-response
</code></pre>
<p>I tested this against the SSE fixture shipped in the pi-wokey repo. It matches, and flipping a single word in the body makes it print <code>MISMATCH</code>. Two limits apply. It doesn't check the signature or the attestation, so a malicious relay could just recompute a hash. And slow streams may contain <code>: wokey-transport-keepalive-v1</code> comment lines, which the official verifier strips (only when the result matches the signed hash) and this script doesn't. Use it to build intuition, not as a security check.</p>
<h2>Step 4: Full offline verification with the official verifier</h2>
<p>The real verifier is TypeScript in the same repo. It needs Node; <code>tsx</code> is installed as a dev dependency.</p>
<pre><code class="language-bash">git clone https://github.com/focuxdot/proof-of-observation
cd proof-of-observation/verifier
npm install
npx tsx tee-verify-stream.ts ../../captured-response \
  --pcr0 &lt;CANONICAL_PCR0&gt; \
  --host api.anthropic.com
</code></pre>
<ul>
<li><code>--pcr0</code> is required. Take it from the repo's README / <code>docs/tee-reproducible-build.md</code>, or better, reproduce it yourself (below). As of this writing (October 2026), the published production value is <code>437cbab8c2e5dd11a35ae5b062fe115623a013910b7c26b333e2b3af477944d630fb1dcd76fa9a9b1eefdf1d1021dec2</code>. Don't copy it from a blog post, though; check the repo.</li>
<li><code>--host</code> is optional. If you pass it, the verifier checks that the signed <code>upstream_host</code> matches. If you leave it out, it only displays the host.</li>
</ul>
<p>The verifier checks the COSE/ECDSA-P384 attestation chain up to the AWS Nitro root, whose SHA-256 fingerprint is pinned in <code>verify-attestation-cose.mjs</code> (<code>64:1A:03:21:â€¦:79:BB:5B</code>; compare it against <a href="https://docs.aws.amazon.com/enclaves/latest/user/verify-root.html">AWS's documentation</a>). It then checks PCR0, the key and nonce binding, the Ed25519 statement signature, and the body hash. A pass on everything exits <code>0</code>, and any failure exits <code>1</code>.</p>
<p>Two practical notes:</p>
<ul>
<li><strong>The CLI output labels are in Chinese.</strong> Check marks (âœ…/âŒ) and the exit code are enough to script against.</li>
<li><strong>Verify soon after capturing.</strong> The Nitro leaf certificate is short-lived (hours), and the verifier checks certificate validity against the current time. The check runs fully offline, but an old capture will fail that step.</li>
</ul>
<p>As a negative test, running it on pi-wokey's synthetic fixture (<code>test-fixtures/anthropic-opus-5-5.sse</code>, which has a placeholder attestation) shows the host and statement-signature checks passing and the attestation check failing, so the overall verdict is âŒ. That's the behavior you want: no real attestation, no green.</p>
<p>There's also a <a href="https://focuxdot.github.io/proof-of-observation/tee-verify.html">browser verifier</a> that runs locally in the page; its source is <code>docs/tee-verify.html</code> in the repo. For non-streaming requests, add <code>-H "x-wokey-tee-proof-mode: multipart"</code> and save the whole <code>multipart/mixed</code> body. The same CLI accepts it.</p>
<h2>Optional: reproduce PCR0 yourself</h2>
<p>The whole scheme rests on PCR0 being something <em>you</em> can recompute, not a number the operator gives you:</p>
<pre><code class="language-bash">cd proof-of-observation/enclave
git checkout &lt;revision listed in docs/tee-reproducible-build.md&gt;
bash reproducible-build.sh   # double --no-cache build, two byte-identical PCR0s
</code></pre>
<p>This needs an <code>aarch64</code> host with Docker and the AWS Nitro CLI. The pinned versions are in <code>docs/tee-reproducible-build.md</code>, which also names the source revision behind the current production value.</p>
<h2>If you live in a coding agent</h2>
<p>If you use the <a href="https://pi.dev">pi</a> coding agent, the community extension <a href="https://github.com/kslamph/pi-wokey">pi-wokey</a> (<code>pi install npm:pi-wokey</code>) does all of this on every response. It vendors the verifier code, pins PCR0, and warns when a reply fails or can't be proven. It's an independent project, not affiliated with Wokey, and its README is the clearest statement of the limits above.</p>
<h2>Takeaways</h2>
<ul>
<li>Changing <code>base_url</code> is the easy part. Chat Completions responses aren't proven, so if you want proofs, use streaming Messages / Responses or the multipart header.</li>
<li>Save exact bytes (<code>curl -o</code>), verify with a pinned PCR0 and the AWS root, and do it soon after capture.</li>
<li>A pass means the relay didn't alter what the upstream TLS endpoint sent. It doesn't mean the provider ran the model it named. No relay can prove that today, because the providers don't sign their responses.</li>
</ul>
<p>If you'd rather just try the flow end to end, <a href="https://wokey.ai">Wokey</a> is the deployment used here. The verifier and enclave source are open, so you can check them before you trust the result.</p>
]]></content:encoded></item></channel></rss>