diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c16a0..4d395a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,114 @@ # Changelog +## 0.10.0 — 2026-09-05 + +### Added + +- **API-key rail — BlockRun accounts instead of a wallet.** Set + `BLOCKRUN_API_KEY=brk_live_...` (or `--api-key`, or `api_key="brk_live_..."` + on a single call) and every surface routes to `api.blockrun.ai` with a Bearer + header. No wallet, no private key, no chain, no `[solana]` extra, no USDC. + Keys are issued at https://user.blockrun.ai; credit is topped up by card and + debited at the published list price. + + This is the wall that stopped most people trying the package. The x402 wallet + is the right primitive for an agent — it can hold one, and it needs no + account — but a human evaluating the library for an afternoon does not want + to fund a Solana wallet first. Both are now first-class, and everything above + the credential is byte-identical: same catalogue, same OpenAI and Anthropic + wire formats, same streaming, same native fingerprint passthrough. + + Coverage on the new rail: `/v1/chat/completions` (incl. streaming), + `/v1/messages`, `/v1/responses`, `/v1/images/generations`, `/v1/images/edits`, + `/v1/videos` + `/v1/videos/generations` (submit, poll and download), + `/v1/audio/speech`, `/v1/audio/generations`, `/v1/audio/sound-effects`, and + `/v1/models`. The one gap is the **native Gemini protocol** + (`/v1beta/models/...`), which `api.blockrun.ai` does not publish: the sidecar + answers **501** naming the wallet rail and the `/v1/chat/completions` route to + the same models, rather than letting a bare 404 come back from a host that + never served it. Gemini models themselves work on both rails. + +- **`cost_source: "blockrun_account"` in the audit log.** A call billed to + prepaid credit has no per-call on-chain charge, and never will. Tagging those + rows `litellm_estimate` would have claimed a real number existed and was + missed; the new value says the authoritative figure lives in the account + ledger at user.blockrun.ai. `cost_usd` on such a row is LiteLLM's + token × list-price estimate (`null` when LiteLLM has no price for the model), + which is the same thing it always was — only the label is now honest about it. + A reconciliation job can finally tell "no charge exists here" apart from "we + failed to read the charge". + +- **`Retry-After` survives the hop.** A 429 from the account API carries the + wait time every stock OpenAI and Anthropic client schedules its backoff + from. It now rides on the raised `APIError` and is put back on the sidecar's + response, on both the passthrough and media routes. Dropping it turned a + client that would have waited into one that spins against a rate limit. + +- **A malformed API key is an error, not a silent fallback.** A `brk_` prefix + with nothing usable after it, or whitespace inside it, or a `BLOCKRUN_API_KEY` + that is plainly a wallet key, now raises. Falling through to the wallet rail + was the wrong failure: the caller asked for the account rail, so the next + call either 402s for a wallet they do not have or spends from one they did + not mean to touch. Passing both an account key and a wallet key at once is + refused for the same reason — there is no obviously right reading, and the + two spend different money. + +- **`--api-key` / `--chain` on the sidecar**, plus `BLOCKRUN_API_KEY`, + `BLOCKRUN_API_BASE_URL` and `BLOCKRUN_CHAIN`. Startup fails fast on the rail + it is actually configured for and logs which one it picked — the old check + demanded a wallet unconditionally, which an API-key deployment does not have. + +### Changed + +- **Solana is now the default chain** for the wallet rail. An unconfigured host + previously fell through to Base (the SDK's own default). Solana settles in + about a second for a fraction of the fee, so it is where a caller who never + made a choice should land. + + **This does not break an existing Base deployment.** When nothing explicit is + set and the host holds *only* a Base credential — `BLOCKRUN_WALLET_KEY`, + `BASE_CHAIN_WALLET_KEY`, or `~/.blockrun/.session` — the adapter keeps using + Base and logs a one-line warning telling you to set `BLOCKRUN_CHAIN=base` to + make the choice explicit. A hex Base key cannot be parsed by the SVM signer, + so silently flipping such a host would have turned an upgrade into an outage. + + Anything explicit still wins, in this order: an `api_url` / `api_base` + argument, then `BLOCKRUN_API_URL`, then `BLOCKRUN_CHAIN`, then an explicitly + passed wallet key's own format, then `~/.blockrun/payment-chain` / + `~/.blockrun/.chain`, then the probe above. Note that `BLOCKRUN_CHAIN=solana` + on a Base-only host is honoured and fails with "no Solana wallet" — once a + choice has been made, quietly serving the other chain would be a lie about + which chain moved money. + + Two of those steps exist because the first cut of this change got the default + wrong. **The chain the BlockRun CLI recorded is now read**: someone who ran + the interactive setup and chose Base had already answered this question, and + a "default" that ignored their answer was exactly the breakage the + compatibility branch above exists to prevent. And **an explicitly passed + wallet key picks its own chain from its format** — a hex key is not base58, + so the key on the call is a better answer than anything on disk. Both were + caught in review of #32; credit to @KillerQueen-Z. + +- **Wallet keys are hashed out of the client-cache keys.** Those strings are + dict keys, so they surfaced in a `repr` of the cache, a `KeyError`, and + anything that dumps locals during a crash. Hashing costs nothing and takes a + private key out of all of those paths. + +- `api_key` on a `litellm.completion(...)` call now carries **either** + credential. The `brk_` prefix selects the account rail; anything else is a + wallet key, exactly as before. No wallet key format begins with `brk_`, so + existing callers are unaffected and no second parameter was needed. + +- The sidecar strips the client's `Authorization` header before forwarding on + the API-key rail and substitutes its own. `BLOCKRUN_PROXY_TOKEN` is a + local guard; forwarding it to a remote host would leak the credential it + exists to protect. + +- A media failure on the API-key rail is flagged `settlement_status: "unknown"` + the way an optimistic-settle chain is. The account is metered from real + upstream usage the moment the gateway answers, so a failure on the way back + may still have cost money — which is exactly what that flag means. + ## 0.9.1 — 2026-08-07 ### Added diff --git a/README.md b/README.md index 7efd41a..506585f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Python](https://img.shields.io/pypi/pyversions/blockrun-litellm.svg)](https://pypi.org/project/blockrun-litellm/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -LiteLLM adapter for [BlockRun](https://blockrun.ai) — call x402-paid AI models through [LiteLLM](https://github.com/BerriAI/litellm) with zero changes to your existing code. **Base and Solana chains supported.** +LiteLLM adapter for [BlockRun](https://blockrun.ai) — call 90+ AI models through [LiteLLM](https://github.com/BerriAI/litellm) with zero changes to your existing code. Pay with a **BlockRun API key** (card top-up, no wallet) or with an **x402 USDC wallet** on **Solana or Base**. 📚 **Full docs in [`docs/`](docs/)** — bilingual (English + 中文): - [`CUSTOMER-ONBOARDING`](docs/CUSTOMER-ONBOARDING.md) / [`中文`](docs/CUSTOMER-ONBOARDING.zh.md) — 5-minute walkthrough, both modes @@ -14,78 +14,139 @@ LiteLLM adapter for [BlockRun](https://blockrun.ai) — call x402-paid AI models - [Chat Completions API](https://blockrun.ai/docs/api-reference/chat-completions) - [Models & pricing](https://blockrun.ai/docs/api-reference/models) -> **TL;DR** — BlockRun's `/v1/chat/completions` is already OpenAI-compatible at the protocol level. The only thing that differs is *authentication*: BlockRun uses per-request x402 wallet signatures (non-custodial USDC micropayments on Base / Solana), not a Bearer API key. This package bridges that gap. +> **TL;DR** — BlockRun's `/v1/chat/completions` is already OpenAI-compatible at the protocol level. What differs is *billing*. Two ways to settle: an ordinary API key billed against prepaid credit, or per-request x402 wallet signatures (non-custodial USDC on Solana / Base). This package handles both, and everything above the credential is identical. [中文文档见底部 / Chinese docs at the bottom](#中文文档) --- -## Two ways to integrate +## Get an API key (30 seconds) -| Mode | Best for | What it looks like | +1. Sign in at **[user.blockrun.ai](https://user.blockrun.ai)** with Google. +2. **Billing → Add credit.** Card payment, $5 minimum. The processing fee (5.5% + $0.30) is charged at purchase, so every model then bills at the published list price — no per-call minimum, no per-call fee, no markup. +3. **API keys → Create key.** You get a `brk_live_…` key, shown once. + +```bash +export BLOCKRUN_API_KEY=brk_live_... +``` + +That is the whole setup. No wallet, no chain, no USDC, no gas. + +Prefer to pay from a wallet you control? Skip to [**Pay with an x402 wallet**](#pay-with-an-x402-wallet-solana--base) — it needs no account at all. + +--- + +## Two ways to pay + +| | **API key** | **x402 wallet** | |---|---|---| -| **1. Custom provider** (in-process) | Apps using the LiteLLM **Python library** | `litellm.completion(model="blockrun/openai/gpt-5.5", ...)` | -| **2. Local proxy** (sidecar) | Apps using the LiteLLM **Proxy Server** (or any OpenAI client) | `api_base="http://localhost:4001/v1"` | +| Set up | Sign in at [user.blockrun.ai](https://user.blockrun.ai), top up by card | Fund a wallet with USDC | +| Credential | `BLOCKRUN_API_KEY=brk_live_…` | `SOLANA_WALLET_KEY` / `BLOCKRUN_WALLET_KEY` | +| Endpoint | `https://api.blockrun.ai` | `https://sol.blockrun.ai/api` (default) or `https://blockrun.ai/api` | +| Billing | Prepaid credit, debited at list price | Per-call USDC settled on chain | +| Account needed | Yes | **No** | +| Chain | None — payment never touches a chain | Solana or Base | +| Per-call cost reported | No — see the ledger at [user.blockrun.ai](https://user.blockrun.ai) | **Yes** — the exact settled charge, per call | +| Where spend shows up | Dashboard → Activity | The chain, plus `x-blockrun-settlement` | +| Native Gemini (`/v1beta`) | Not available | Available | +| Extras to install | none | `[solana]` for the Solana signer | -Both modes share the same underlying wallet/signing flow (via the [`blockrun-llm`](https://github.com/BlockRunAI/blockrun-llm) SDK), so they behave identically. Pick whichever fits your deployment. +Everything else is the same on both: the same model catalogue, the same OpenAI/Anthropic wire formats, the same streaming, the same native fingerprint passthrough. -### Verified end-to-end against the live BlockRun gateway +**Precedence.** A key wins whenever one is present. `BLOCKRUN_API_KEY` (or `--api-key`, or `api_key="brk_live_…"` on a call) selects the API-key rail; with no key the adapter falls back to the wallet rail. Wallet keys are never confused with account keys — only a `brk_` prefix selects the account rail, and no private-key format starts with one. -Both modes have been validated against `https://blockrun.ai/api` using the free `nvidia/deepseek-v4-flash` model: +--- -``` -$ python -c " -> import litellm -> from blockrun_litellm import register; register() -> r = litellm.completion( -> model='blockrun/nvidia/deepseek-v4-flash', -> messages=[{'role':'user','content':'Reply with exactly: pong'}], -> max_tokens=20, temperature=0.0) -> print(r.choices[0].message.content)" -pong +## Two ways to integrate -$ curl -sS http://127.0.0.1:4001/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{"model":"nvidia/deepseek-v4-flash","messages":[{"role":"user","content":"Reply with exactly: proxy-ok"}]}' -{"id":"a710c144c68c42f7a319fb93e9b9b5a0","object":"chat.completion","model":"nvidia/deepseek-v4-flash", - "choices":[{"index":0,"message":{"role":"assistant","content":"proxy-ok"},...}],"usage":{...}} -``` +| Mode | Best for | What it looks like | +|---|---|---| +| **1. Custom provider** (in-process) | Apps using the LiteLLM **Python library** | `litellm.completion(model="blockrun/openai/gpt-5.5", ...)` | +| **2. Local proxy** (sidecar) | Apps using the LiteLLM **Proxy Server** (or any OpenAI client) | `api_base="http://localhost:4001/v1"` | + +Both modes work on both rails and behave identically. Pick whichever fits your deployment. --- ## Install ```bash -# Base chain only — minimal +# API key, or a Solana/Base wallet used from the Python library pip install blockrun-litellm -# Base chain + local OpenAI-compatible proxy (FastAPI/uvicorn) +# ...plus the local OpenAI-compatible proxy (FastAPI/uvicorn) pip install 'blockrun-litellm[proxy]' -# Base + Solana (adds the x402 SVM toolchain) +# ...plus the x402 SVM signer, needed ONLY to pay from a Solana wallet pip install 'blockrun-litellm[proxy,solana]' ``` -Requires Python ≥ 3.9. +Requires Python ≥ 3.9. On the API-key rail the `solana` extra is unnecessary — there is no signing to do. + +--- -## Chains supported +## Quick start -| Chain | Gateway URL | Wallet env var | Status | -|---|---|---|---| -| Base (USDC) | `https://blockrun.ai/api` *(default)* | `BLOCKRUN_WALLET_KEY` | sync + async, streaming | -| Solana (USDC) | `https://sol.blockrun.ai/api` | `SOLANA_WALLET_KEY` | sync + async, streaming on both (since 0.3.1) | +### With an API key + +```python +import litellm +from blockrun_litellm import register + +register() # idempotent; adds "blockrun" to litellm.custom_provider_map + +# BLOCKRUN_API_KEY is read from the environment; or pass api_key= per call. +r = litellm.completion( + model="blockrun/openai/gpt-5.5", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=64, +) +print(r.choices[0].message.content) +``` + +Or as a sidecar for anything that speaks OpenAI HTTP: -To route on Solana, pass `api_base="https://sol.blockrun.ai/api"` plus `api_key=` to `litellm.completion(...)` — the adapter detects the chain from the URL and uses the right SDK client. +```bash +blockrun-litellm-proxy --port 4001 --api-key brk_live_... +curl -s http://127.0.0.1:4001/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{"model":"openai/gpt-5.5","messages":[{"role":"user","content":"hi"}]}' +``` + +If all you want is chat, you do not need this package at all on the API-key rail — `https://api.blockrun.ai/v1` is OpenAI-compatible, so any OpenAI SDK works by changing `base_url`. The package earns its place when you want LiteLLM routing/fallbacks, the local audit log, or the media and Anthropic surfaces under one proxy. --- -## Configure your wallet (one-time) +## Pay with an x402 wallet (Solana / Base) + +No account, no sign-up: fund a wallet with USDC and every request settles itself. This is the agent-native path — an autonomous agent can hold a wallet, but it cannot fill in a card form. + +### Chains supported + +| Chain | Gateway URL | Wallet env var | Notes | +|---|---|---|---| +| **Solana (USDC)** — *default* | `https://sol.blockrun.ai/api` | `SOLANA_WALLET_KEY` | Sub-second settlement, lowest fee. Needs the `[solana]` extra. Sync + async, streaming. | +| Base (USDC) | `https://blockrun.ai/api` | `BLOCKRUN_WALLET_KEY` | Sync + async, streaming. | + +**Solana is the default chain as of 0.10.0.** Previously an unconfigured host used Base. Pick a chain explicitly with `BLOCKRUN_CHAIN`: + +```bash +export BLOCKRUN_CHAIN=solana # default +export BLOCKRUN_CHAIN=base +``` + +or point at a gateway directly with `BLOCKRUN_API_URL` / `--api-url` / `api_base=` (which always wins over `BLOCKRUN_CHAIN`). + +> **Upgrading from ≤ 0.9.x on Base?** Nothing breaks. When no chain is configured and the host holds *only* a Base wallet, the adapter keeps using Base and logs a one-line warning. Set `BLOCKRUN_CHAIN=base` to make the choice explicit and silence it. + +### Configure your wallet (one-time) -The `blockrun-llm` SDK signs each request locally with an EVM (Base chain) private key. **The key never leaves your machine.** Three ways to provide it: +The `blockrun-llm` SDK signs each request locally. **The key never leaves your machine** — only signatures travel. ```bash # Option A — environment variable (recommended for servers) -export BLOCKRUN_WALLET_KEY=0xYOUR_BASE_CHAIN_PRIVATE_KEY +export SOLANA_WALLET_KEY=YOUR_SOLANA_PRIVATE_KEY # Solana (default chain) +export BLOCKRUN_WALLET_KEY=0xYOUR_BASE_PRIVATE_KEY # Base # Option B — auto-create + fund a new wallet (interactive, shows QR for funding) python -c "from blockrun_llm import setup_agent_wallet; setup_agent_wallet()" @@ -97,6 +158,25 @@ python -c "from blockrun_llm import setup_agent_wallet; setup_agent_wallet()" --- +## What each rail can serve + +Every surface below works on both rails except where noted. + +| Surface | API key | Wallet | +|---|---|---| +| `POST /v1/chat/completions` (+ streaming) | ✅ | ✅ | +| `POST /v1/messages` — native Anthropic | ✅ | ✅ | +| `POST /v1/responses` — OpenAI Responses | ✅ | ✅ | +| `POST /v1/images/generations`, `/v1/images/edits` | ✅ | ✅ | +| `POST /v1/videos`, `/v1/videos/generations` (+ poll, download) | ✅ | ✅ | +| `POST /v1/audio/speech`, `/v1/audio/generations`, `/v1/audio/sound-effects` | ✅ | ✅ | +| `GET /v1/models` | ✅ | ✅ | +| `POST /v1beta/models/{model}:generateContent` — **native Gemini** | ❌ 501 | ✅ | + +Native Gemini is the one gap: `api.blockrun.ai` does not publish `/v1beta`. The proxy answers 501 with that explanation rather than a bare 404. Gemini models themselves are reachable on both rails through `/v1/chat/completions` (`model="google/gemini-3-pro"`); only Google's own protocol needs a wallet. + +--- + ## Mode 1 — Custom provider (Python library) The shortest path if your app already calls `litellm.completion()` directly. @@ -139,14 +219,22 @@ assert is_known_model("blockrun/anthropic/claude-opus-5") The gateway is authoritative and accepts newly released IDs before a package update; query `https://blockrun.ai/api/v1/models` whenever you need live metadata. -### 1c. Override the wallet per-call (optional) +### 1c. Override the credential per-call (optional) + +`api_key` carries either credential — the `brk_` prefix decides which rail serves the call, so one parameter covers both and existing wallet code is untouched: ```python -response = litellm.completion( - model="blockrun/openai/gpt-5.5", - messages=[...], - api_key="0xANOTHER_PRIVATE_KEY", # passed to blockrun-llm as wallet -) +# Account rail — billed to prepaid credit +litellm.completion(model="blockrun/openai/gpt-5.5", messages=[...], + api_key="brk_live_...") + +# Wallet rail — x402, signed locally +litellm.completion(model="blockrun/openai/gpt-5.5", messages=[...], + api_key="0xANOTHER_PRIVATE_KEY") + +# Wallet rail, specific chain +litellm.completion(model="blockrun/openai/gpt-5.5", messages=[...], + api_base="https://blockrun.ai/api", api_key="0xBASE_KEY") ``` ### 1d. Async @@ -173,26 +261,39 @@ If you're running the **LiteLLM Proxy Server** (`litellm --config config.yaml`), ### 2a. Start the proxy ```bash -export BLOCKRUN_WALLET_KEY=0xYOUR_KEY +# API-key rail +blockrun-litellm-proxy --port 4001 --api-key brk_live_... + +# x402 wallet rail (Solana by default) +export SOLANA_WALLET_KEY=YOUR_SOLANA_PRIVATE_KEY blockrun-litellm-proxy --port 4001 # → uvicorn running at http://127.0.0.1:4001 ``` +The sidecar fails fast at startup if it has neither credential, and tells you which rail it picked. + Flags: | Flag | Default | Purpose | |---|---|---| | `--host` | `127.0.0.1` | Bind interface. **Keep loopback** unless you set `BLOCKRUN_PROXY_TOKEN`. | | `--port` | `4001` | Bind port | -| `--api-url` | `https://blockrun.ai/api` | Override BlockRun gateway endpoint | +| `--api-key` | *(unset)* | BlockRun API key (`brk_live_…`). Selects the account rail; no chain involved. Env: `BLOCKRUN_API_KEY` | +| `--chain` | `solana` | Wallet-rail chain: `solana` or `base`. Ignored with `--api-key`. Env: `BLOCKRUN_CHAIN` | +| `--api-url` | *(the `--chain` gateway)* | Override the gateway endpoint outright | | `--log-level` | `info` | `critical`/`error`/`warning`/`info`/`debug`/`trace` | Environment variables (no CLI flag): | Env var | Default | Purpose | |---|---|---| +| `BLOCKRUN_API_KEY` | *(unset)* | Account key. When set, every route serves from prepaid credit. | +| `BLOCKRUN_API_BASE_URL` | `https://api.blockrun.ai` | Account API endpoint (staging overrides). | +| `BLOCKRUN_CHAIN` | `solana` | Wallet-rail chain. | | `BLOCKRUN_MAX_CONCURRENT` | `100` | Max in-flight requests. Excess requests queue inside the sidecar. See table below for tuning guidance. | -| `BLOCKRUN_PROXY_TOKEN` | *(unset)* | Optional Bearer token guard on all sidecar endpoints. | +| `BLOCKRUN_PROXY_TOKEN` | *(unset)* | Optional Bearer token guard on all sidecar endpoints. Never forwarded upstream. | + +> **The credential stays in the sidecar.** Clients on the same host send `BLOCKRUN_PROXY_TOKEN` (if you set one) and never see your API key or wallet key — the sidecar strips the client's `Authorization` header before forwarding and substitutes its own. #### High-concurrency tuning @@ -374,10 +475,10 @@ curl http://localhost:4001/v1/chat/completions \ | Method | Path | Notes | |---|---|---| | `POST` | `/v1/chat/completions` | OpenAI Chat Completions. `stream=True` returns `text/event-stream`; otherwise JSON. | -| `POST` | `/v1beta/models/{model}:generateContent` | Native Gemini JSON request and response, with automatic x402 payment. | -| `POST` | `/v1beta/models/{model}:streamGenerateContent` | Native Gemini SSE, with automatic x402 payment. | +| `POST` | `/v1beta/models/{model}:generateContent` | Native Gemini JSON request and response, with automatic x402 payment. **Wallet rail only** — 501 on the API-key rail. | +| `POST` | `/v1beta/models/{model}:streamGenerateContent` | Native Gemini SSE, with automatic x402 payment. **Wallet rail only** — 501 on the API-key rail. | | `POST` | `/v1/responses` | OpenAI Responses API, bridged onto Chat Completions (`input`→`messages`, `output`/`response.*` SSE out). Text-in/text-out; for advanced tool/state flows use `/v1/chat/completions`. | -| `POST` | `/v1/images/generations` | OpenAI Image Generations. Accepts `prompt`, `model`, `size`, `n`, and `quality` (Solana only — see below). | +| `POST` | `/v1/images/generations` | OpenAI Image Generations. Accepts `prompt`, `model`, `size`, `n`, and `quality` (Solana or API key — see below). | | `POST` | `/v1/images/edits` | OpenAI-compatible image editing. Accepts JSON data URIs or multipart `image`/`image[]`; supports multiple source images, `mask`, and `quality` (Solana only). `/v1/images/image2image` is an alias. | | `POST` | `/v1/videos` | OpenAI Videos API create (what LiteLLM's video routes call) — returns a job object immediately | | `GET` | `/v1/videos/{id}` | OpenAI Videos API status poll (`queued` → `in_progress` → `completed`/`failed`) | @@ -392,6 +493,12 @@ curl http://localhost:4001/v1/chat/completions \ ### 2e. Native Gemini protocol +> **Wallet rail only.** `api.blockrun.ai` does not publish `/v1beta`, so with +> `BLOCKRUN_API_KEY` set the sidecar answers 501 with that explanation instead +> of a bare 404. Gemini *models* still work on the API-key rail through +> `/v1/chat/completions` with `model="google/gemini-3-pro"`; only Google's own +> protocol needs a wallet. + Native Gemini calls use the sidecar root (`http://localhost:4001`), not the OpenAI `/v1` base. The sidecar preserves Gemini request/response JSON and SSE frames and adds the x402 payment signature using the configured wallet. @@ -590,9 +697,20 @@ Opt-in JSONL logger captures every call — works on both Base and Solana, sync ``` ts, iso, model, provider, messages, completion, usage{prompt_tokens, completion_tokens, total_tokens}, -latency_ms, stream, cost_usd, status, error_type, error_message, request_id +latency_ms, stream, cost_usd, cost_source, estimated_cost_usd, settlement, +status, error_type, error_message, request_id ``` +`cost_source` says how much to trust `cost_usd`: + +| `cost_source` | Meaning | +|---|---| +| `blockrun_x402` | `cost_usd` **is** the settled on-chain charge for this call. Wallet rail. | +| `blockrun_account` | Billed to prepaid account credit — no per-call on-chain charge exists. `cost_usd` here is LiteLLM's token × list-price estimate (`null` when LiteLLM has no price for the model); the **authoritative figure is the ledger at [user.blockrun.ai](https://user.blockrun.ai) → Activity**. | +| `litellm_estimate` | Wallet rail, but no charge was reported (free/cached call, or an older SDK). The estimate is standing in. | + +The distinction between the last two matters for reconciliation: `blockrun_account` means "a real number exists, elsewhere", not "we tried to read one and failed". + ### Mode 1 — one line ```python @@ -620,8 +738,11 @@ litellm_settings: | File / env var | What | Configurable? | |---|---|---| -| `BLOCKRUN_WALLET_KEY` (env) | Base private key | yes | +| `BLOCKRUN_API_KEY` (env) | BlockRun account key (`brk_live_…`) — issued at [user.blockrun.ai](https://user.blockrun.ai) | yes | +| `BLOCKRUN_API_BASE_URL` (env) | Account API endpoint (default `https://api.blockrun.ai`) | yes | +| `BLOCKRUN_CHAIN` (env) | Wallet-rail chain, `solana` (default) or `base` | yes | | `SOLANA_WALLET_KEY` (env) | Solana private key | yes | +| `BLOCKRUN_WALLET_KEY` (env) | Base private key | yes | | `~/.blockrun/.session` | Auto-created Base wallet | — | | `~/.blockrun/.solana-session` | Auto-created Solana wallet | — | | `~/.blockrun/litellm_calls.jsonl` | LiteLLM request log | `BLOCKRUN_LITELLM_LOG` env or `enable_local_logging(path)` | @@ -645,26 +766,48 @@ The `examples/` directory has copy-paste-ready snippets: ## How it works (under the hood) +One adapter, two rails. The rail is chosen by which credential is present; nothing above that point differs. + +**API-key rail** — a plain authenticated request: + +``` +┌─────────────────┐ OpenAI dict ┌──────────────────────┐ Authorization: Bearer brk_… ┌──────────────────┐ +│ Your app / │ ─────────────────▶ │ blockrun-litellm │ ─────────────────────────────▶ │ api.blockrun.ai │ +│ LiteLLM / │ │ (provider OR proxy) │ ◀──── 200 + chat response ──── │ (your account) │ +│ OpenAI SDK │ └──────────────────────┘ └────────┬─────────┘ +└─────────────────┘ │ debits + │ prepaid + ▼ credit + user.blockrun.ai +``` + +1. Caller sends an OpenAI Chat Completions dict. +2. `blockrun-litellm` whitelists the params and POSTs them with your key. +3. The account API meters real upstream token usage against the published price sheet and debits your credit. +4. Spend lands in the account ledger; the response comes back verbatim. + +**Wallet rail** — x402, no account: + ``` ┌─────────────────┐ OpenAI dict ┌──────────────────────┐ POST /v1/chat/completions ┌────────────────┐ -│ Your app / │ ─────────────────▶ │ blockrun-litellm │ ────────────────────────────▶ │ blockrun.ai │ -│ LiteLLM / │ │ (provider OR proxy) │ ◀──── 402 + payment-required ─│ gateway │ -│ OpenAI SDK │ │ ↓ │ │ │ -└─────────────────┘ │ blockrun-llm SDK │ ───── EIP-712 signed retry ──▶│ │ +│ Your app / │ ─────────────────▶ │ blockrun-litellm │ ────────────────────────────▶ │ sol.blockrun.ai│ +│ LiteLLM / │ │ (provider OR proxy) │ ◀──── 402 + payment-required ─│ (or blockrun. │ +│ OpenAI SDK │ │ ↓ │ │ ai for Base) │ +└─────────────────┘ │ blockrun-llm SDK │ ───── signed retry ─────────▶ │ │ │ (local signing) │ ◀──── 200 + chat response ────│ │ └──────────────────────┘ └────────────────┘ ▲ │ private key (stays local, signs only) ┌──────────────────────┐ - │ BLOCKRUN_WALLET_KEY │ + │ SOLANA_WALLET_KEY │ │ or ~/.blockrun/ │ └──────────────────────┘ ``` 1. Caller sends an OpenAI Chat Completions dict. 2. `blockrun-litellm` whitelists the params and dispatches through `blockrun-llm`. -3. `blockrun-llm` posts to BlockRun, receives a 402 with payment requirements, signs an EIP-712 payment locally with your wallet, and retries. -4. BlockRun verifies the signature on-chain, settles the USDC micropayment, runs the inference, and returns the response. +3. `blockrun-llm` posts to BlockRun, receives a 402 with payment requirements, signs the payment locally with your wallet (SVM on Solana, EIP-712 on Base), and retries. +4. BlockRun verifies the signature on-chain, settles the USDC micropayment, runs the inference, and returns the response — plus the exact charge, which the adapter surfaces as `cost_usd`. 5. `blockrun-litellm` returns the dumped pydantic model as a plain OpenAI dict (or `litellm.ModelResponse` in provider mode). --- @@ -674,14 +817,23 @@ The `examples/` directory has copy-paste-ready snippets: **Q: Does this support streaming?** Yes, as of v0.2.0. Pass `stream=True` and the adapter routes through `blockrun-llm`'s `chat_completion_stream()` (SDK ≥ 0.20.0). The 402 → sign-locally → retry-with-PAYMENT-SIGNATURE dance happens before the first chunk; once the upstream switches to `text/event-stream`, chunks are forwarded straight through (provider mode → `litellm.GenericStreamingChunk`, proxy mode → OpenAI-style `data: \n\n` SSE). Caveats inherited from the gateway: `search_parameters` and the Responses-API models (`codex`, `gpt-5.4-pro`) reject streaming server-side with 400. +**Q: Do I need a crypto wallet to use this?** +No. Sign in at [user.blockrun.ai](https://user.blockrun.ai), top up by card, and set `BLOCKRUN_API_KEY`. The wallet rail stays available for anyone who prefers to pay in USDC directly — including agents, which can hold a wallet but cannot fill in a card form. + +**Q: I already use this with a wallet. Does the API key change anything for me?** +No. The wallet rail is untouched; the account rail only activates when a `brk_`-prefixed key is present. The one behaviour change in 0.10.0 is the default chain — see the next question. + +**Q: How do I switch between Solana and Base?** +`BLOCKRUN_CHAIN=solana` (the default since 0.10.0) or `BLOCKRUN_CHAIN=base`; `--chain` on the sidecar; or point `BLOCKRUN_API_URL` / `api_base=` at a gateway directly, which wins over both. A host that holds only a Base wallet and sets nothing keeps using Base, with a warning. + **Q: Where does my private key live?** -On your machine only — `BLOCKRUN_WALLET_KEY` env var, or `~/.blockrun/.session` if you used `setup_agent_wallet()`. The proxy and provider both read from those sources via `blockrun-llm`. Only EIP-712 signatures are transmitted. +On your machine only — `SOLANA_WALLET_KEY` / `BLOCKRUN_WALLET_KEY` env vars, or `~/.blockrun/.solana-session` / `~/.blockrun/.session` if you used `setup_agent_wallet()`. The proxy and provider both read from those sources via `blockrun-llm`. Only signatures are transmitted. On the API-key rail there is no private key at all. -**Q: How do I switch between Base and Solana?** -Today this adapter wires to BlockRun's Base gateway (USDC on Base). Solana support tracks the `blockrun-llm` `SolanaLLMClient` and will be added in a follow-up release. +**Q: Why is `cost_usd` empty when I use an API key?** +Because there is no per-call on-chain charge to report — the call was billed against prepaid credit. The audit row says so with `cost_source: "blockrun_account"`, and the authoritative spend is at [user.blockrun.ai](https://user.blockrun.ai) → Activity. The wallet rail still reports the exact settled charge per call. **Q: Can I run the proxy in Docker / k8s?** -Yes — it's a vanilla FastAPI app. Pass the wallet key via secret (env var), bind to `0.0.0.0` only inside a private network, and set `BLOCKRUN_PROXY_TOKEN` for an additional auth layer. +Yes — it's a vanilla FastAPI app. Pass `BLOCKRUN_API_KEY` (or the wallet key) via secret, bind to `0.0.0.0` only inside a private network, and set `BLOCKRUN_PROXY_TOKEN` for an additional auth layer. The sidecar never forwards a client's `Authorization` header upstream. **Q: Is this affiliated with LiteLLM (BerriAI)?** No — this is an independent adapter built by the BlockRun team. LiteLLM is a great project; we're just plugging into its custom-provider hooks. @@ -707,9 +859,42 @@ MIT. See [LICENSE](LICENSE). # 中文文档 -[BlockRun](https://blockrun.ai) 的 [LiteLLM](https://github.com/BerriAI/litellm) 适配层 —— 用 LiteLLM 调用 BlockRun 上的 AI 模型,**完全零改动**。 +[BlockRun](https://blockrun.ai) 的 [LiteLLM](https://github.com/BerriAI/litellm) 适配层 —— 用 LiteLLM 调用 BlockRun 上 90+ 个 AI 模型,**完全零改动**。可以用 **BlockRun API Key**(信用卡充值,不需要钱包),也可以用 **x402 USDC 钱包**(**Solana** 或 Base)。 + +> **一句话:** BlockRun 的 `/v1/chat/completions` 协议层就是 OpenAI 兼容的,区别只在*怎么付钱*。两条路:一条是普通 API Key,从预付余额扣;一条是按次 x402 钱包签名(非托管 USDC,Solana / Base)。这个包两条都支持,凭证之上的一切完全一致。 + +## 领 API Key(30 秒) + +1. 用 Google 账号登录 **[user.blockrun.ai](https://user.blockrun.ai)**。 +2. **Billing → Add credit**:信用卡充值,最低 $5。手续费(5.5% + $0.30)在**充值时**一次性收取,之后每个模型都按官网标价计费 —— 没有每次调用的最低消费,没有每次调用的手续费,没有加价。 +3. **API keys → Create key**:拿到 `brk_live_…`,只显示一次。 + +```bash +export BLOCKRUN_API_KEY=brk_live_... +``` + +到此配置就结束了。不需要钱包、不需要链、不需要 USDC、不需要 gas。 -> **一句话:** BlockRun 的 `/v1/chat/completions` 协议层就是 OpenAI 兼容的,唯一区别是认证方式 —— BlockRun 用 x402 钱包签名(按次 USDC 微支付,非托管),不是 Bearer API Key。这个包就是把这层差异填平。 +想用自己的钱包付?看下面[**用 x402 钱包付费**](#用-x402-钱包付费solana--base) —— 那条路完全不需要注册账号。 + +## 两种付费方式 + +| | **API Key** | **x402 钱包** | +|---|---|---| +| 怎么开通 | 登录 [user.blockrun.ai](https://user.blockrun.ai) 刷卡充值 | 给钱包充 USDC | +| 凭证 | `BLOCKRUN_API_KEY=brk_live_…` | `SOLANA_WALLET_KEY` / `BLOCKRUN_WALLET_KEY` | +| 端点 | `https://api.blockrun.ai` | `https://sol.blockrun.ai/api`(默认)或 `https://blockrun.ai/api` | +| 计费 | 预付余额,按标价扣 | 每次调用链上结算 USDC | +| 要不要账号 | 要 | **不要** | +| 链 | 没有链 | Solana 或 Base | +| 单次成本上报 | 没有 —— 查 [user.blockrun.ai](https://user.blockrun.ai) 账单 | **有** —— 每次调用返回真实结算金额 | +| 消费在哪看 | Dashboard → Activity | 链上,以及 `x-blockrun-settlement` | +| 原生 Gemini (`/v1beta`) | 不支持 | 支持 | +| 需要装的 extra | 无 | Solana 签名需要 `[solana]` | + +其余完全相同:同一份模型目录、同样的 OpenAI / Anthropic 协议、同样的流式、同样的原生指纹透传。 + +**优先级:** 只要检测到 API Key 就走 Key 这条路。`BLOCKRUN_API_KEY`(或 `--api-key`,或调用时传 `api_key="brk_live_…"`)选账号路;没有 Key 就回落到钱包路。钱包私钥不会被误判成 API Key —— 只有 `brk_` 前缀才会切到账号路,而任何私钥格式都不以它开头。 ## 两种对接方式 @@ -718,34 +903,91 @@ MIT. See [LICENSE](LICENSE). | **1. 自定义 Provider**(进程内) | 用 LiteLLM **Python 库**的应用 | `litellm.completion(model="blockrun/openai/gpt-5.5", ...)` | | **2. 本地代理**(sidecar) | 用 LiteLLM **Proxy Server** 的、或任何 OpenAI 客户端 | `api_base="http://localhost:4001/v1"` | -底层都走 [`blockrun-llm`](https://github.com/BlockRunAI/blockrun-llm) SDK 做签名和 x402 支付,两种模式行为一致。按你的部署方式选一种就行。 +两种模式在两条付费路上都能用,行为一致。按你的部署方式选一种就行。 ## 快速上手 ### 安装 ```bash -# 只装自定义 provider +# API Key,或在 Python 库里用 Solana / Base 钱包 pip install blockrun-litellm -# 同时装本地代理(带 FastAPI/uvicorn) +# 再加本地代理(FastAPI/uvicorn) pip install 'blockrun-litellm[proxy]' + +# 再加 x402 SVM 签名器 —— 只有用 Solana 钱包付费才需要 +pip install 'blockrun-litellm[proxy,solana]' +``` + +用 API Key 的话不需要 `solana` extra —— 那条路没有签名这一步。 + +### 用 API Key(推荐先试这个) + +```python +import litellm +from blockrun_litellm import register + +register() + +# 从环境变量读 BLOCKRUN_API_KEY;也可以每次调用传 api_key= +r = litellm.completion( + model="blockrun/openai/gpt-5.5", + messages=[{"role": "user", "content": "你好"}], + max_tokens=64, +) +print(r.choices[0].message.content) +``` + +sidecar 版: + +```bash +blockrun-litellm-proxy --port 4001 --api-key brk_live_... ``` +## 用 x402 钱包付费(Solana / Base) + +不用注册、不用账号:给钱包充 USDC,每次请求自己结算。这是给 Agent 用的那条路 —— Agent 可以持有钱包,但填不了信用卡表单。 + +| 链 | 网关 URL | 钱包环境变量 | 说明 | +|---|---|---|---| +| **Solana (USDC)** —— *默认* | `https://sol.blockrun.ai/api` | `SOLANA_WALLET_KEY` | 亚秒级结算、费用最低。需要 `[solana]` extra。同步 / 异步 / 流式都支持。 | +| Base (USDC) | `https://blockrun.ai/api` | `BLOCKRUN_WALLET_KEY` | 同步 / 异步 / 流式都支持。 | + +**从 0.10.0 起 Solana 是默认链**(之前没配置时默认走 Base)。显式指定: + +```bash +export BLOCKRUN_CHAIN=solana # 默认 +export BLOCKRUN_CHAIN=base +``` + +也可以直接用 `BLOCKRUN_API_URL` / `--api-url` / `api_base=` 指到具体网关(优先级高于 `BLOCKRUN_CHAIN`)。 + +完整优先级(从高到低):`api_base=` / `--api-url` → `BLOCKRUN_API_URL` → `BLOCKRUN_CHAIN` → 显式传入的钱包私钥自身格式(hex 是 Base,base58 是 Solana)→ BlockRun CLI 记在 `~/.blockrun/payment-chain` 或 `~/.blockrun/.chain` 里的链 → 机器上有哪些钱包 → Solana。 + +> **从 ≤ 0.9.x 升级、原来用 Base 的?** 不会坏。没有配置链、且机器上只有 Base 钱包时,适配器仍然走 Base,并打一行警告。设 `BLOCKRUN_CHAIN=base` 把选择写死,警告就没了。 + ### 配钱包(一次性) ```bash # 方式 A — 环境变量(服务端推荐) -export BLOCKRUN_WALLET_KEY=0xYOUR_BASE_CHAIN_PRIVATE_KEY +export SOLANA_WALLET_KEY=YOUR_SOLANA_PRIVATE_KEY # Solana(默认链) +export BLOCKRUN_WALLET_KEY=0xYOUR_BASE_PRIVATE_KEY # Base # 方式 B — 自动创建并扫码充值(交互式) python -c "from blockrun_llm import setup_agent_wallet; setup_agent_wallet()" ``` -私钥**只在本地用于 EIP-712 签名**,永远不会离开你的机器。 +私钥**只在本地签名**,永远不会离开你的机器。 > 💡 想零成本试一遍?用免费模型 `nvidia/deepseek-v4-flash` —— 代码完全一样,钱包流程一样,结算 $0。 +### 两条路各自支持哪些接口 + +除下面注明的一条外,所有接口两条路都支持:`/v1/chat/completions`(含流式)、`/v1/messages`(Anthropic 原生)、`/v1/responses`、`/v1/images/*`、`/v1/videos*`、`/v1/audio/*`、`/v1/models`。 + +唯一的缺口是**原生 Gemini 协议** `/v1beta/models/{model}:generateContent` —— `api.blockrun.ai` 没有发布这个接口,用 API Key 时 sidecar 返回 501 并说明原因。Gemini **模型本身**两条路都能用,走 `/v1/chat/completions` 传 `model="google/gemini-3-pro"` 即可;只有 Google 自家协议需要钱包。 + ### 模式 1:自定义 Provider ```python @@ -767,8 +1009,9 @@ print(response.choices[0].message.content) ### 模式 2:本地代理 ```bash -# 1) 启动 sidecar -export BLOCKRUN_WALLET_KEY=0xYOUR_KEY +# 1) 启动 sidecar —— 二选一 +blockrun-litellm-proxy --port 4001 --api-key brk_live_... # API Key +export SOLANA_WALLET_KEY=YOUR_SOLANA_PRIVATE_KEY # 或 x402 钱包(默认 Solana) blockrun-litellm-proxy --port 4001 # 2) LiteLLM Proxy 配置 (config.yaml) @@ -862,11 +1105,23 @@ BlockRun 额外参数: **Q:支持流式吗?** v0.2.0 起完全支持。`stream=True` 时适配层走 `blockrun-llm` 的 `chat_completion_stream()`(SDK ≥ 0.20.0),402 → 本地签名 → 带 PAYMENT-SIGNATURE 重试这条链在第一个 chunk 之前完成;上游切到 `text/event-stream` 后 chunks 直接透传(Provider 模式 → `litellm.GenericStreamingChunk`,Proxy 模式 → OpenAI 标准 `data: \n\n`)。后端继承的限制:`search_parameters` 和 Responses-API 模型(`codex`、`gpt-5.4-pro`)在服务端就拒绝流式(400)。 +**Q:一定要有加密钱包吗?** +不用。去 [user.blockrun.ai](https://user.blockrun.ai) 登录、刷卡充值、设 `BLOCKRUN_API_KEY` 就行。钱包那条路继续保留,给愿意直接用 USDC 付费的人 —— 尤其是 Agent,它能持有钱包,但填不了信用卡表单。 + +**Q:我已经在用钱包,加了 API Key 会影响我吗?** +不会。钱包那条路一行没动,只有出现 `brk_` 前缀的凭证时才会切到账号路。0.10.0 唯一的行为变化是默认链,见下一条。 + +**Q:怎么在 Solana 和 Base 之间切?** +`BLOCKRUN_CHAIN=solana`(0.10.0 起是默认)或 `BLOCKRUN_CHAIN=base`;sidecar 用 `--chain`;也可以直接把 `BLOCKRUN_API_URL` / `api_base=` 指到具体网关(优先级最高)。用 CLI 交互式选过链的(`~/.blockrun/.chain`)会被尊重;机器上只有 Base 钱包又什么都没配的,仍然走 Base,并打一行警告。 + **Q:私钥放哪?** -只在本地 —— `BLOCKRUN_WALLET_KEY` 环境变量,或 `setup_agent_wallet()` 创建的 `~/.blockrun/.session`。Provider 和 Proxy 都通过 `blockrun-llm` 读取。链上只看到签名,看不到私钥。 +只在本地 —— `SOLANA_WALLET_KEY` / `BLOCKRUN_WALLET_KEY` 环境变量,或 `setup_agent_wallet()` 创建的 `~/.blockrun/.solana-session` / `~/.blockrun/.session`。Provider 和 Proxy 都通过 `blockrun-llm` 读取。链上只看到签名,看不到私钥。用 API Key 时根本不存在私钥。 + +**Q:用 API Key 时 `cost_usd` 为什么是空的?** +因为这条路没有"单次链上扣款"这回事 —— 这次调用是从预付余额扣的。审计行会写明 `cost_source: "blockrun_account"`,权威金额在 [user.blockrun.ai](https://user.blockrun.ai) → Activity。钱包那条路仍然每次返回真实结算金额。 **Q:Docker / k8s 部署?** -代理是普通的 FastAPI 应用。密钥用 secret 注入,对外只暴露内网,可选 `BLOCKRUN_PROXY_TOKEN` 加一层 Bearer 鉴权。 +代理是普通的 FastAPI 应用。`BLOCKRUN_API_KEY`(或钱包私钥)用 secret 注入,对外只暴露内网,可选 `BLOCKRUN_PROXY_TOKEN` 加一层 Bearer 鉴权。sidecar 不会把客户端的 `Authorization` 头转发到上游。 **Q:和 BerriAI 是什么关系?** 没关系。这是 BlockRun 团队独立维护的适配层,挂在 LiteLLM 的 custom provider 接口上。 diff --git a/blockrun_litellm/__init__.py b/blockrun_litellm/__init__.py index 8e3a6e3..3dc39af 100644 --- a/blockrun_litellm/__init__.py +++ b/blockrun_litellm/__init__.py @@ -1,7 +1,18 @@ """ blockrun-litellm — LiteLLM adapter for BlockRun. -Two integration modes: +Two ways to pay, chosen by which credential is present: + +* **API key** — ``BLOCKRUN_API_KEY=brk_live_...`` (issued at + https://user.blockrun.ai, topped up by card). Calls go to + ``api.blockrun.ai`` and are billed against prepaid credit. No wallet, no + chain, no USDC. +* **x402 wallet** — ``SOLANA_WALLET_KEY`` (default chain) or + ``BLOCKRUN_WALLET_KEY`` (Base). Each call is signed locally by the + ``blockrun-llm`` SDK and settles on chain; your private key never leaves the + host. No account needed. + +Two integration modes, both of which work on either rail: 1. **Custom provider** (in-process): @@ -15,11 +26,8 @@ 2. **Local OpenAI-compatible proxy** (sidecar): - $ blockrun-litellm-proxy --port 4001 + $ blockrun-litellm-proxy --port 4001 --api-key brk_live_... # then point LiteLLM at http://localhost:4001/v1 - -The adapter delegates x402 wallet signing and payment to the -``blockrun-llm`` SDK; your private key never leaves the host. """ from blockrun_litellm.logger import enable_local_logging @@ -40,4 +48,4 @@ "model_ids", "register", ] -__version__ = "0.9.1" +__version__ = "0.10.0" diff --git a/blockrun_litellm/_adapter.py b/blockrun_litellm/_adapter.py index 3ce9397..3605f9f 100644 --- a/blockrun_litellm/_adapter.py +++ b/blockrun_litellm/_adapter.py @@ -25,14 +25,18 @@ import asyncio import concurrent.futures +import hashlib import logging import os import threading +from pathlib import Path from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Union from blockrun_llm import AsyncLLMClient, ImageClient, LLMClient from blockrun_llm.types import APIError, ChatCompletionChunk, PaymentError +from blockrun_litellm import _apikey + try: from blockrun_llm import AsyncSolanaLLMClient, SolanaLLMClient @@ -82,18 +86,205 @@ def _canonical_video_model(model: Optional[str]) -> Optional[str]: return model -def _is_solana_url(api_url: Optional[str]) -> bool: +# --------------------------------------------------------------------------- +# Chain selection (wallet rail only) +# --------------------------------------------------------------------------- +# Solana is the default chain as of 0.10.0. It settles in roughly a second for +# a fraction of a cent, where a Base settlement is both slower and dearer, so +# it is the chain a new caller should land on without having to know there was +# a choice. +# +# Flipping a default cannot brick a running deployment, though, and a host that +# has only ever held a Base wallet would otherwise start failing the moment it +# upgraded — its key is hex, and the SVM signer cannot use it. So the flip is +# conditional on what is actually on the box: an implicit default picks Base, +# loudly, when Base is the only credential present. Anything explicit +# (``BLOCKRUN_CHAIN``, ``BLOCKRUN_API_URL``, an ``api_url`` argument) always +# wins over both, including a ``BLOCKRUN_CHAIN=solana`` on a host with no +# Solana wallet — that must fail with "no wallet", not silently serve Base. + +_CHAIN_ALIASES: Dict[str, str] = { + "solana": SOLANA_API_URL, + "sol": SOLANA_API_URL, + "svm": SOLANA_API_URL, + "base": BASE_API_URL, + "evm": BASE_API_URL, +} + +_SOLANA_KEY_ENVS = ("SOLANA_WALLET_KEY",) +_BASE_KEY_ENVS = ("BLOCKRUN_WALLET_KEY", "BASE_CHAIN_WALLET_KEY") +# Written by the SDK's interactive wallet setup; the same files it auto-loads. +_SOLANA_SESSION = Path.home() / ".blockrun" / ".solana-session" +_BASE_SESSION = Path.home() / ".blockrun" / ".session" +# Where the BlockRun CLI/SDK records a chain the user picked interactively. +# Honouring it is not a nicety: someone who ran the setup flow and chose Base +# has already answered this question, and a default that ignores their answer +# is a worse failure than the one the default exists to prevent. +# +# Order matters, and not hypothetically — both files exist on a real machine +# here and DISAGREE (``payment-chain`` says solana, the older ``.chain`` says +# base). ``payment-chain`` is the current name and wins; ``.chain`` is the +# legacy spelling, read only so an older install still gets an answer. +_CHAIN_FILES = ( + Path.home() / ".blockrun" / "payment-chain", + Path.home() / ".blockrun" / ".chain", +) + + +def _chain_from_file() -> Optional[str]: + """The chain the CLI last wrote, if any. Unreadable or empty means absent.""" + for path in _CHAIN_FILES: + try: + if path.is_file(): + value = path.read_text().strip().lower() + if value in _CHAIN_ALIASES: + return value + if value: + _log.warning("%s contains %r, which is not a known chain", path, value) + except OSError: + continue + return None + + +def _chain_from_key(private_key: Optional[str]) -> Optional[str]: + """Infer the chain from an explicitly passed wallet key's shape. + + A Base key is 32 hex bytes, with or without the ``0x``; a Solana key is + base58 and neither. When a caller hands us a specific key, its own format + is a better answer than anything on disk — the key on the call is the + wallet that will pay, and routing it to the other chain's signer fails. + """ + if not private_key: + return None + candidate = private_key.strip() + body = candidate[2:] if candidate.lower().startswith("0x") else candidate + if len(body) == 64: + try: + int(body, 16) + except ValueError: + return "solana" + return "base" + return "solana" if candidate else None + + +def _has_wallet(envs: Any, session: Path) -> bool: + if any(os.environ.get(name) for name in envs): + return True + try: + return session.is_file() + except OSError: # unreadable home dir — treat as absent, never crash + return False + + +# The credential probe touches the filesystem, so it is memoized against the +# env vars that can change its answer. Re-statting ~/.blockrun on every request +# would be a syscall per completion for a value that almost never moves. +_default_url_cache: Dict[Any, str] = {} + + +def _default_wallet_api_url(private_key: Optional[str] = None) -> str: + """Gateway URL when nothing explicit says which chain to use. + + Precedence below the env/URL settings: an explicitly passed key's own shape, + then the chain the CLI recorded, then what wallets exist on the host. + """ + chain = (os.environ.get("BLOCKRUN_CHAIN") or "").strip().lower() + if chain: + resolved = _CHAIN_ALIASES.get(chain) + if resolved: + return resolved + _log.warning( + "BLOCKRUN_CHAIN=%r is not a known chain (expected one of %s); " + "falling back to auto-detection", + chain, + ", ".join(sorted(_CHAIN_ALIASES)), + ) + from_key = _chain_from_key(private_key) + if from_key: + return _CHAIN_ALIASES[from_key] + from_file = _chain_from_file() + if from_file: + return _CHAIN_ALIASES[from_file] + cache_key = tuple(os.environ.get(name, "") for name in _SOLANA_KEY_ENVS + _BASE_KEY_ENVS) + cached = _default_url_cache.get(cache_key) + if cached is not None: + return cached + if not _has_wallet(_SOLANA_KEY_ENVS, _SOLANA_SESSION) and _has_wallet( + _BASE_KEY_ENVS, _BASE_SESSION + ): + _log.warning( + "Solana is now the default BlockRun chain, but only a Base wallet was " + "found on this host, so this process keeps using Base. Set " + "BLOCKRUN_CHAIN=base to make that explicit (and silence this warning), " + "or set SOLANA_WALLET_KEY to move to Solana." + ) + resolved = BASE_API_URL + else: + resolved = SOLANA_API_URL + _default_url_cache[cache_key] = resolved + return resolved + + +def resolve_api_url( + api_url: Optional[str] = None, private_key: Optional[str] = None +) -> str: + """The gateway URL a wallet-rail call will actually use. + + Precedence: explicit argument, then ``BLOCKRUN_API_URL``, then the chain + default. Always concrete — callers pass the result straight to an SDK + client rather than relying on the SDK's own (Base) default, which no longer + matches ours. + """ + return ( + api_url + or os.environ.get("BLOCKRUN_API_URL") + or _default_wallet_api_url(private_key) + ) + + +def _reset_chain_cache_for_tests() -> None: + _default_url_cache.clear() + + +def _is_solana_url(api_url: Optional[str], private_key: Optional[str] = None) -> bool: """Sniff whether the effective gateway URL points at Solana. - Falls back to the ``BLOCKRUN_API_URL`` env var when no explicit - ``api_url`` is passed. This matters for the FastAPI sidecar: the + Falls back to ``BLOCKRUN_API_URL`` and then the chain default when no + explicit ``api_url`` is passed. This matters for the FastAPI sidecar: the request handlers don't forward an ``api_url`` arg, so without the env-var fallback we'd silently route Solana traffic to the Base async client and crash inside the EVM payment encoder (``eth_abi.AddressEncoder`` rejects base58 mint addresses). """ - resolved = api_url or os.environ.get("BLOCKRUN_API_URL", "") - return bool(resolved) and "sol.blockrun.ai" in resolved + return "sol.blockrun.ai" in resolve_api_url(api_url, private_key) + + +# --------------------------------------------------------------------------- +# Rail selection (API key vs wallet) +# --------------------------------------------------------------------------- + + +def _route_key(api_key: Optional[str], private_key: Optional[str]) -> Optional[str]: + """The BlockRun API key serving this call, or ``None`` for the wallet rail. + + ``private_key`` is consulted too because that is the argument LiteLLM's + ``api_key`` lands in (see :mod:`blockrun_litellm.provider`), so + ``litellm.completion(..., api_key="brk_live_...")`` picks the account rail + without the caller needing a second parameter name. A hex or base58 wallet + key cannot be mistaken for one — see :func:`_apikey.looks_like_api_key`. + + Passing BOTH an account key and a wallet key is refused rather than ranked. + There is no reading of that call that is obviously right, and the two + choices spend different money: guessing would either bill an account the + caller meant to leave alone or move USDC out of a wallet they did not + intend to touch. + """ + if _apikey.looks_like_api_key(api_key) and private_key: + raise ValueError( + "Pass either api_key (a brk_ account key) or private_key (an x402 " + "wallet key), not both — they bill different money." + ) + return _apikey.resolve_api_key(api_key, private_key) # --------------------------------------------------------------------------- @@ -150,9 +341,17 @@ def _wallet_env_var(api_url: Optional[str]) -> str: def _client_key(api_url: Optional[str], private_key: Optional[str]) -> str: - chain = "solana" if _is_solana_url(api_url) else "base" + """Cache key for a wallet client, with the key itself hashed. + + The raw private key used to be part of this string, and these keys are dict + keys — they surface in a ``repr`` of the cache, in a KeyError, in anything + that dumps locals during a crash. Hashing costs nothing here and takes a + wallet key out of every one of those paths. + """ + chain = "solana" if _is_solana_url(api_url, private_key) else "base" fallback_env = os.environ.get(_wallet_env_var(api_url), "") - return f"{chain}::{api_url or ''}::{private_key or fallback_env}" + secret = hashlib.sha256((private_key or fallback_env).encode()).hexdigest() + return f"{chain}::{api_url or ''}::{secret}" def get_sync_client( @@ -178,11 +377,15 @@ def get_sync_client( # explicit key was passed. client = SolanaLLMClient( private_key=private_key, - api_url=api_url or SOLANA_API_URL, + api_url=resolve_api_url(api_url), timeout=_CHAT_TIMEOUT, ) else: - client = LLMClient(private_key=private_key, api_url=api_url, timeout=_CHAT_TIMEOUT) + client = LLMClient( + private_key=private_key, + api_url=resolve_api_url(api_url), + timeout=_CHAT_TIMEOUT, + ) _sync_clients[key] = client return client @@ -210,12 +413,14 @@ def get_async_client( ) client = AsyncSolanaLLMClient( private_key=private_key, - api_url=api_url or SOLANA_API_URL, + api_url=resolve_api_url(api_url), timeout=_CHAT_TIMEOUT, ) else: client = AsyncLLMClient( - private_key=private_key, api_url=api_url, timeout=_CHAT_TIMEOUT + private_key=private_key, + api_url=resolve_api_url(api_url), + timeout=_CHAT_TIMEOUT, ) _async_clients[key] = client return client @@ -285,6 +490,14 @@ def _strip_real_cost(payload: Dict[str, Any], client: Any) -> Dict[str, Any]: return {"cost_usd": cost, "settlement": settlement} +# The API-key rail settles against prepaid credit, off chain, so there is no +# per-call charge to report and no settlement receipt to decode. Saying so +# explicitly — rather than leaving the key absent — keeps the downstream +# "real cost or estimate?" branch a single lookup on both rails, and makes the +# absence deliberate rather than a hole someone later fills with an estimate. +_NO_ONCHAIN_COST: Dict[str, Any] = {"cost_usd": None, "settlement": None, "rail": "api_key"} + + # --------------------------------------------------------------------------- # Non-streaming entrypoints # --------------------------------------------------------------------------- @@ -296,6 +509,7 @@ def chat_completion_sync( *, api_url: Optional[str] = None, private_key: Optional[str] = None, + api_key: Optional[str] = None, **openai_kwargs: Any, ) -> Dict[str, Any]: """ @@ -309,6 +523,16 @@ def chat_completion_sync( For ``stream=True``, use :func:`chat_completion_stream_sync` instead. """ openai_kwargs.pop("stream", None) + key = _route_key(api_key, private_key) + if key: + payload = _apikey.post_json( + "/v1/chat/completions", + {"model": model, "messages": messages, **_filter_kwargs(openai_kwargs)}, + api_key=key, + timeout=_CHAT_TIMEOUT, + ) + payload[_BLOCKRUN_META_KEY] = dict(_NO_ONCHAIN_COST) + return payload is_solana = _is_solana_url(api_url) kwargs = _filter_kwargs(openai_kwargs, is_solana=is_solana) client = get_sync_client(api_url=api_url, private_key=private_key) @@ -324,14 +548,27 @@ async def chat_completion_async( *, api_url: Optional[str] = None, private_key: Optional[str] = None, + api_key: Optional[str] = None, **openai_kwargs: Any, ) -> Dict[str, Any]: """Async variant of :func:`chat_completion_sync`. - **Base only today.** Solana ``api_url`` raises ``NotImplementedError`` - via :func:`get_async_client` since the SDK has no async Solana client. + **Base only today** on the wallet rail: a Solana ``api_url`` raises + ``NotImplementedError`` via :func:`get_async_client` since the SDK has no + async Solana client. The API-key rail has no such limit — it is plain HTTP + with no chain-specific signer — so async works there regardless. """ openai_kwargs.pop("stream", None) + key = _route_key(api_key, private_key) + if key: + payload = await _apikey.apost_json( + "/v1/chat/completions", + {"model": model, "messages": messages, **_filter_kwargs(openai_kwargs)}, + api_key=key, + timeout=_CHAT_TIMEOUT, + ) + payload[_BLOCKRUN_META_KEY] = dict(_NO_ONCHAIN_COST) + return payload is_solana = _is_solana_url(api_url) kwargs = _filter_kwargs(openai_kwargs, is_solana=is_solana) client = get_async_client(api_url=api_url, private_key=private_key) @@ -352,6 +589,7 @@ def chat_completion_stream_sync( *, api_url: Optional[str] = None, private_key: Optional[str] = None, + api_key: Optional[str] = None, **openai_kwargs: Any, ) -> Iterator[ChatCompletionChunk]: """ @@ -363,6 +601,14 @@ def chat_completion_stream_sync( ``GenericStreamingChunk``, FastAPI ``data: \\n\\n``, etc.). """ openai_kwargs.pop("stream", None) + key = _route_key(api_key, private_key) + if key: + yield from _apikey.stream_chat( + {"model": model, "messages": messages, **_filter_kwargs(openai_kwargs)}, + api_key=key, + timeout=_CHAT_TIMEOUT, + ) + return is_solana = _is_solana_url(api_url) kwargs = _filter_kwargs(openai_kwargs, is_solana=is_solana) client = get_sync_client(api_url=api_url, private_key=private_key) @@ -375,14 +621,25 @@ async def chat_completion_stream_async( *, api_url: Optional[str] = None, private_key: Optional[str] = None, + api_key: Optional[str] = None, **openai_kwargs: Any, ) -> AsyncIterator[ChatCompletionChunk]: """Async variant of :func:`chat_completion_stream_sync`. - **Base only today.** A Solana ``api_url`` raises - ``NotImplementedError`` since the SDK has no async Solana client. + **Base only today** on the wallet rail: a Solana ``api_url`` raises + ``NotImplementedError`` since the SDK has no async Solana client. The + API-key rail is plain HTTP and has no such limit. """ openai_kwargs.pop("stream", None) + key = _route_key(api_key, private_key) + if key: + async for chunk in _apikey.astream_chat( + {"model": model, "messages": messages, **_filter_kwargs(openai_kwargs)}, + api_key=key, + timeout=_CHAT_TIMEOUT, + ): + yield chunk + return is_solana = _is_solana_url(api_url) kwargs = _filter_kwargs(openai_kwargs, is_solana=is_solana) client = get_async_client(api_url=api_url, private_key=private_key) @@ -440,7 +697,7 @@ def get_image_client( ) client = SolanaLLMClient( private_key=private_key, - api_url=api_url or SOLANA_API_URL, + api_url=resolve_api_url(api_url), # Raise the per-image-request timeout ceiling. The SDK caps # each image POST at ``image_timeout`` (SolanaLLMClient # default 200s); slow models such as ``openai/gpt-image-2`` @@ -455,7 +712,7 @@ def get_image_client( image_timeout=_solana_image_timeout(), ) else: - client = ImageClient(private_key=private_key, api_url=api_url) + client = ImageClient(private_key=private_key, api_url=resolve_api_url(api_url)) _image_clients[key] = client return client @@ -537,6 +794,72 @@ def _invoke_image_edit( return client.edit(prompt, image, **kwargs) +# --------------------------------------------------------------------------- +# API-key bodies for the media surfaces +# --------------------------------------------------------------------------- +# The SDK builds these bodies itself and they are not exported, so the API-key +# rail rebuilds them here. They must match what the SDK sends — including its +# defaults for an omitted model or size — or the two rails would answer the +# same call with different pictures. The defaults are read off the SDK classes +# rather than copied, so a default that moves upstream moves here too. + + +def _image_defaults() -> tuple: + return ImageClient.DEFAULT_MODEL, ImageClient.DEFAULT_SIZE + + +def _image_body( + prompt: str, + *, + model: Optional[str], + size: Optional[str], + n: int, + quality: Optional[str], +) -> Dict[str, Any]: + default_model, default_size = _image_defaults() + body: Dict[str, Any] = { + "model": model or default_model, + "prompt": prompt, + "size": size or default_size, + "n": n, + } + # No chain here, so no `quality` suppression: the Solana-only rule exists + # because the Base gateway has no such field, and which gateway serves an + # account call is BlockRun's routing decision, not the caller's. Forwarding + # it lets the account API accept or ignore it the way it does for any other + # OpenAI Images parameter. + if quality is not None: + body["quality"] = quality + return body + + +def _image_edit_body( + prompt: str, + image: Any, + *, + model: Optional[str], + mask: Optional[str], + size: Optional[str], + n: int, + quality: Optional[str], +) -> Dict[str, Any]: + # ``openai/gpt-image-2`` is ImageClient.edit's own hardcoded default and has + # no class constant to read, so it is repeated rather than referenced. + _, default_size = _image_defaults() + body: Dict[str, Any] = { + "model": model or "openai/gpt-image-2", + "prompt": prompt, + "image": image, + "size": size or default_size, + "n": n, + } + if mask is not None: + body["mask"] = mask + if quality is not None: + body["quality"] = quality + return body + + def image_generation_sync( prompt: str, *, @@ -546,7 +869,16 @@ def image_generation_sync( quality: Optional[str] = None, api_url: Optional[str] = None, private_key: Optional[str] = None, + api_key: Optional[str] = None, ) -> Dict[str, Any]: + key = _route_key(api_key, private_key) + if key: + return _apikey.post_json( + "/v1/images/generations", + _image_body(prompt, model=model, size=size, n=n, quality=quality), + api_key=key, + timeout=_solana_image_timeout(), + ) client = get_image_client(api_url=api_url, private_key=private_key) response = _invoke_image_generate( client, prompt, model=model, size=size, n=n, quality=quality @@ -563,7 +895,16 @@ async def image_generation_async( quality: Optional[str] = None, api_url: Optional[str] = None, private_key: Optional[str] = None, + api_key: Optional[str] = None, ) -> Dict[str, Any]: + key = _route_key(api_key, private_key) + if key: + return await _apikey.apost_json( + "/v1/images/generations", + _image_body(prompt, model=model, size=size, n=n, quality=quality), + api_key=key, + timeout=_solana_image_timeout(), + ) client = get_image_client(api_url=api_url, private_key=private_key) loop = asyncio.get_event_loop() response = await loop.run_in_executor( @@ -586,7 +927,18 @@ def image_edit_sync( quality: Optional[str] = None, api_url: Optional[str] = None, private_key: Optional[str] = None, + api_key: Optional[str] = None, ) -> Dict[str, Any]: + key = _route_key(api_key, private_key) + if key: + return _apikey.post_json( + "/v1/images/edits", + _image_edit_body( + prompt, image, model=model, mask=mask, size=size, n=n, quality=quality + ), + api_key=key, + timeout=_solana_image_timeout(), + ) client = get_image_client(api_url=api_url, private_key=private_key) response = _invoke_image_edit( client, @@ -612,7 +964,18 @@ async def image_edit_async( quality: Optional[str] = None, api_url: Optional[str] = None, private_key: Optional[str] = None, + api_key: Optional[str] = None, ) -> Dict[str, Any]: + key = _route_key(api_key, private_key) + if key: + return await _apikey.apost_json( + "/v1/images/edits", + _image_edit_body( + prompt, image, model=model, mask=mask, size=size, n=n, quality=quality + ), + api_key=key, + timeout=_solana_image_timeout(), + ) client = get_image_client(api_url=api_url, private_key=private_key) loop = asyncio.get_event_loop() response = await loop.run_in_executor( @@ -665,6 +1028,23 @@ async def image_edit_async( _BASE_MEDIA_CLASSES = {"video": "VideoClient", "music": "MusicClient", "speech": "SpeechClient"} +def _sdk_default(cls_name: str, attr: str) -> Optional[str]: + """A default model the SDK would have applied, read off its client class. + + Looked up by name for the same reason :data:`_BASE_MEDIA_CLASSES` is: these + classes are imported lazily so an SDK that predates one of them degrades to + a clear error at call time rather than an ImportError at import time. The + API-key rail needs the values because it builds the request bodies the SDK + would otherwise have built, and a default that drifts upstream must drift + here too rather than being frozen into a copy. + """ + import blockrun_llm + + cls = getattr(blockrun_llm, cls_name, None) + value = getattr(cls, attr, None) if cls is not None else None + return value if isinstance(value, str) else None + + def _get_media_client(medium: str, api_url: Optional[str], private_key: Optional[str]) -> Any: """Dedicated Base client for ``medium``, or the unified SolanaLLMClient (which get_image_client already builds + caches) when the URL is Solana.""" @@ -677,7 +1057,7 @@ def _get_media_client(medium: str, api_url: Optional[str], private_key: Optional with _lock: client = _media_clients.get(key) if client is None: - client = base_cls(private_key=private_key, api_url=api_url) + client = base_cls(private_key=private_key, api_url=resolve_api_url(api_url)) _media_clients[key] = client return client @@ -764,6 +1144,7 @@ async def video_generation_async( model: Optional[str] = None, api_url: Optional[str] = None, private_key: Optional[str] = None, + api_key: Optional[str] = None, **params: Any, ) -> Dict[str, Any]: """Generate a video. Extra kwargs (see :data:`VIDEO_PARAM_KEYS`) forward to @@ -771,12 +1152,25 @@ async def video_generation_async( arg). Client-supplied ``budget_seconds``/``timeout`` are clamped to the server cap so a request body can't pin a worker thread indefinitely; a malformed (non-numeric) value raises ValueError → HTTP 400 at the proxy.""" - client = get_video_client(api_url=api_url, private_key=private_key) + key = _route_key(api_key, private_key) model = _canonical_video_model(model) params = {k: v for k, v in params.items() if v is not None} for knob in ("budget_seconds", "timeout"): if knob in params: params[knob] = min(float(params[knob]), _VIDEO_BUDGET_CAP_S) + if key: + # ``budget_seconds``/``timeout`` govern how long WE wait, not what the + # gateway is asked to do, so they steer the poll loop instead of riding + # along in the body — which is what the SDK does with them too. + budget = params.pop("budget_seconds", None) or params.pop("timeout", None) + params.pop("timeout", None) + body = {"model": model, "prompt": prompt, **params} + return await _run_media( + lambda: _apikey.submit_and_poll_video(body, api_key=key, budget_seconds=budget), + executor=_long_media_executor, + ceiling=_VIDEO_AWAIT_CEILING_S, + ) + client = get_video_client(api_url=api_url, private_key=private_key) if _is_solana_client(client): video = _solana_media_method(client, "video") response = await _run_media( @@ -802,9 +1196,28 @@ async def music_generation_async( lyrics: Optional[str] = None, api_url: Optional[str] = None, private_key: Optional[str] = None, + api_key: Optional[str] = None, ) -> Dict[str, Any]: """Generate a music track. Raises ValueError (→ HTTP 400 at the proxy) when ``lyrics`` is combined with ``instrumental=True`` — the SDK rejects that.""" + if instrumental and lyrics and lyrics.strip(): + raise ValueError("Cannot specify lyrics when instrumental is True") + key = _route_key(api_key, private_key) + if key: + body: Dict[str, Any] = { + "model": model or _sdk_default("MusicClient", "DEFAULT_MODEL"), + "prompt": prompt, + "instrumental": instrumental, + } + if lyrics and lyrics.strip(): + body["lyrics"] = lyrics.strip() + return await _run_media( + lambda: _apikey.post_json( + "/v1/audio/generations", body, api_key=key, timeout=_MUSIC_AWAIT_CEILING_S + ), + executor=_long_media_executor, + ceiling=_MUSIC_AWAIT_CEILING_S, + ) client = get_music_client(api_url=api_url, private_key=private_key) # Same call shape on both chains; only the method name differs. media_fn = ( @@ -827,11 +1240,25 @@ async def speech_generation_async( speed: Optional[float] = None, api_url: Optional[str] = None, private_key: Optional[str] = None, + api_key: Optional[str] = None, ) -> Dict[str, Any]: """Synthesize speech (TTS).""" - client = get_speech_client(api_url=api_url, private_key=private_key) + key = _route_key(api_key, private_key) kw = {"model": model, "voice": voice, "response_format": response_format, "speed": speed} kw = {k: v for k, v in kw.items() if v is not None} + if key: + body = { + "model": model or _sdk_default("SpeechClient", "DEFAULT_MODEL"), + "input": input, + **kw, + } + return await _run_media( + lambda: _apikey.post_json( + "/v1/audio/speech", body, api_key=key, timeout=_SPEECH_AWAIT_CEILING_S + ), + ceiling=_SPEECH_AWAIT_CEILING_S, + ) + client = get_speech_client(api_url=api_url, private_key=private_key) media_fn = ( _solana_media_method(client, "speech") if _is_solana_client(client) else client.generate ) @@ -848,9 +1275,10 @@ async def sound_effect_async( response_format: Optional[str] = None, api_url: Optional[str] = None, private_key: Optional[str] = None, + api_key: Optional[str] = None, ) -> Dict[str, Any]: """Generate a cinematic sound effect.""" - client = get_speech_client(api_url=api_url, private_key=private_key) + key = _route_key(api_key, private_key) kw = { "model": model, "duration_seconds": duration_seconds, @@ -858,6 +1286,19 @@ async def sound_effect_async( "response_format": response_format, } kw = {k: v for k, v in kw.items() if v is not None} + if key: + body = { + "model": model or _sdk_default("SpeechClient", "DEFAULT_SOUNDFX_MODEL"), + "text": text, + **kw, + } + return await _run_media( + lambda: _apikey.post_json( + "/v1/audio/sound-effects", body, api_key=key, timeout=_SPEECH_AWAIT_CEILING_S + ), + ceiling=_SPEECH_AWAIT_CEILING_S, + ) + client = get_speech_client(api_url=api_url, private_key=private_key) # Both SolanaLLMClient and SpeechClient expose .sound_effect with the same # shape; the guard only matters on SDK versions predating Solana media. if _is_solana_client(client): diff --git a/blockrun_litellm/_apikey.py b/blockrun_litellm/_apikey.py new file mode 100644 index 0000000..f4e83e7 --- /dev/null +++ b/blockrun_litellm/_apikey.py @@ -0,0 +1,572 @@ +""" +The API-key rail — a BlockRun account instead of an x402 wallet. + +Why this exists +--------------- +Everything else in this package pays per call from a wallet the caller holds: +the ``blockrun-llm`` SDK signs an x402 payment (EIP-712 on Base, SVM on +Solana) and the gateway settles it on-chain. That is the agent-native path and +it stays the default for anyone who has a wallet. + +It is also a wall for most people who just want to try the thing. An API key +removes the wallet entirely: you sign in at ``user.blockrun.ai``, top up with a +card, and get a ``brk_live_…`` key that authenticates against +``api.blockrun.ai`` — an OpenAI-shaped surface that fronts the same gateway and +the same model catalogue. BlockRun pays the gateway on your behalf and debits +your prepaid credit at the published list price. + +What this module is +------------------- +A small HTTP transport with the *same return shapes* as the SDK calls it +replaces, so nothing above ``_adapter`` has to know which rail served a +request: + +* chat / responses / media → the gateway's JSON, parsed to a ``dict`` + (identical to ``response.model_dump(exclude_none=True)`` on the SDK path, + because ``api.blockrun.ai`` forwards the gateway's body verbatim) +* streaming chat → ``ChatCompletionChunk`` objects, the exact type + ``LLMClient.chat_completion_stream`` yields + +Deliberately NOT here +--------------------- +* **Chain selection.** There is no chain on this rail. Credit is prepaid off + chain, so there is no Base-vs-Solana choice to make and no wallet to fund. + ``BLOCKRUN_CHAIN`` / ``BLOCKRUN_API_URL`` are ignored when a key is in play. +* **Real per-call cost.** The wallet rail reports the exact on-chain charge + from the x402 settlement header. ``api.blockrun.ai`` returns no such header — + it did not make an on-chain payment for this call — so ``cost_usd`` stays + ``None`` and LiteLLM's token x list-price estimate is what gets logged. + Authoritative spend lives in the account ledger at ``user.blockrun.ai``. + Reporting an estimate as if it were a settled charge would be worse than + reporting nothing, which is why nothing is what this returns. + +Endpoint coverage +----------------- +Chat (incl. streaming), the Anthropic Messages dialect, Responses, images, +video (submit + poll + download), speech, music and sound effects all work. +The native Gemini surface (``/v1beta/models/…``) does **not** — see +:data:`UNSUPPORTED_PATHS`. Callers get a 501 naming the wallet rail as the way +to reach it, rather than a bare 404 from a host that never published it. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +from typing import Any, AsyncIterator, Dict, Iterator, Optional, Tuple + +import httpx +from blockrun_llm.types import APIError, ChatCompletionChunk, PaymentError + +_log = logging.getLogger(__name__) + + +# The account API. Unlike the gateway URLs this has no ``/api`` path segment: +# ``api.blockrun.ai/v1/chat/completions``, not ``.../api/v1/...``. +DEFAULT_API_BASE = "https://api.blockrun.ai" + +# Every BlockRun-issued key starts with this. It is what lets one ``api_key`` +# argument carry either credential without a second parameter: a wallet private +# key is hex (Base) or base58 (Solana) and can never collide with it. +KEY_PREFIX = "brk_" + +# Where a caller goes to get a key and add credit. Quoted in errors, because an +# authentication failure that does not say where to get a working credential is +# a dead end. +PORTAL_URL = "https://user.blockrun.ai" + +# Paths the account API does not publish. The value is what the caller is told. +UNSUPPORTED_PATHS: Dict[str, str] = { + "/v1beta": ( + "The native Gemini protocol (/v1beta/models/...) is served only on the " + "x402 wallet rail. Unset BLOCKRUN_API_KEY and configure a wallet " + "(SOLANA_WALLET_KEY or BLOCKRUN_WALLET_KEY) to use it, or call Gemini " + "through /v1/chat/completions with model=google/gemini-3-pro." + ), +} + +# ``/v1/images/edits`` is the OpenAI spelling and is what this proxy exposes; +# the gateway publishes the same operation as ``image2image`` and the account +# API allowlists it under that name only. Rewriting here keeps the OpenAI alias +# working on both rails instead of 404ing on one of them. +_PATH_REWRITES: Dict[str, str] = { + "/v1/images/edits": "/v1/images/image2image", +} + + +def looks_like_api_key(value: Optional[str]) -> bool: + """True when a credential is a BlockRun API key rather than a wallet key. + + Used to let a single ``api_key`` argument — LiteLLM's, or the SDK's + ``private_key`` — carry either one. The prefix is issued by us and no + private key format begins with it, so the test cannot misfire. + """ + return isinstance(value, str) and value.strip().startswith(KEY_PREFIX) + + +def _validated(key: str) -> str: + """A key that carries the prefix but nothing usable after it is an error. + + Silently falling through to the wallet rail on a truncated or space-mangled + key is the wrong failure: the caller asked for the account rail, the next + call would either 402 for a wallet they do not have or, worse, spend from + one they did not mean to use. A typo in a credential should say so. + """ + stripped = key.strip() + if len(stripped) <= len(KEY_PREFIX) or any(c.isspace() for c in stripped): + raise ValueError( + f"BLOCKRUN_API_KEY does not look like a usable BlockRun key. " + f"Issue one at {PORTAL_URL}/dashboard/keys." + ) + return stripped + + +def resolve_api_key(*candidates: Optional[str]) -> Optional[str]: + """First BlockRun API key among the candidates, else ``BLOCKRUN_API_KEY``. + + Candidates are the per-call credentials in precedence order. A candidate + that is not a BlockRun key is skipped rather than rejected: it is a wallet + key, which is a legitimate value for the same argument. + """ + for candidate in candidates: + if looks_like_api_key(candidate): + return _validated(candidate) # type: ignore[arg-type] + env = os.environ.get("BLOCKRUN_API_KEY", "").strip() + if not env: + return None + if not env.startswith(KEY_PREFIX): + raise ValueError( + f"BLOCKRUN_API_KEY is set but does not start with {KEY_PREFIX!r}. " + f"Unset it to use an x402 wallet, or issue a key at " + f"{PORTAL_URL}/dashboard/keys." + ) + return _validated(env) + + +def api_base() -> str: + """Account API base URL. ``BLOCKRUN_API_BASE_URL`` overrides for staging.""" + return (os.environ.get("BLOCKRUN_API_BASE_URL") or DEFAULT_API_BASE).rstrip("/") + + +def unsupported_reason(path: str) -> Optional[str]: + """Why ``path`` cannot be served on this rail, or ``None`` if it can.""" + for prefix, reason in UNSUPPORTED_PATHS.items(): + if path.startswith(prefix): + return reason + return None + + +def target_url(path: str) -> str: + """Absolute account-API URL for a proxy-relative path.""" + return f"{api_base()}{_PATH_REWRITES.get(path, path)}" + + +def headers(api_key: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: + out = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + if extra: + out.update(extra) + return out + + +# --------------------------------------------------------------------------- +# Shared HTTP clients +# --------------------------------------------------------------------------- +# One pooled client per (sync|async) process, not per call: a fresh +# httpx.Client per request re-runs TLS on every completion and, under the +# proxy's 100-way concurrency, leaks sockets faster than they close. Limits +# mirror the wallet rail's pool, minus the doubling — this rail sends one +# request per call, not a 402 probe plus a signed retry. + +_HTTP_LIMITS = httpx.Limits(max_connections=200, max_keepalive_connections=50) + +_sync_http: Optional[httpx.Client] = None +_async_http: Optional[httpx.AsyncClient] = None +_http_lock = threading.Lock() + + +def _timeout(seconds: Optional[float]) -> httpx.Timeout: + """Long read, short connect. + + A reasoning model can think for minutes before the first byte, so the read + budget has to be generous; a host that is not answering at all should still + fail in seconds rather than inherit that budget. + """ + return httpx.Timeout(seconds or 600.0, connect=15.0) + + +def sync_http() -> httpx.Client: + global _sync_http + with _http_lock: + if _sync_http is None: + _sync_http = httpx.Client(limits=_HTTP_LIMITS, timeout=_timeout(None)) + return _sync_http + + +def async_http() -> httpx.AsyncClient: + global _async_http + with _http_lock: + if _async_http is None: + _async_http = httpx.AsyncClient(limits=_HTTP_LIMITS, timeout=_timeout(None)) + return _async_http + + +def _reset_clients_for_tests() -> None: + """Drop the pooled clients. Test-only; the process otherwise keeps them.""" + global _sync_http, _async_http + with _http_lock: + _sync_http = None + _async_http = None + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +def _error_message(status: int, body: bytes) -> str: + """Pull the human part out of an account-API error body. + + Three shapes reach here: the account API's own OpenAI-style + ``{"error": {"message", "type", "code"}}``, the gateway's flatter + ``{"error": "..."}`` forwarded verbatim, and — on an infrastructure fault — + HTML from a load balancer. The last one must not be echoed as a "message", + so anything unparseable degrades to the status line. + """ + try: + parsed = json.loads(body or b"{}") + except ValueError: + return f"BlockRun API returned HTTP {status}" + err = parsed.get("error") if isinstance(parsed, dict) else None + if isinstance(err, dict): + message = err.get("message") + code = err.get("code") + if isinstance(message, str) and message: + return f"{message} (code={code})" if code else message + if isinstance(err, str) and err: + return err + return f"BlockRun API returned HTTP {status}" + + +def raise_for_status( + status: int, body: bytes, headers: Optional[Any] = None +) -> None: + """Translate an account-API failure into the SDK's exception vocabulary. + + The proxy and the provider already branch on ``PaymentError`` vs + ``APIError``; mapping onto those two means neither has to learn a third. + + * 401/403 → ``APIError``, with the portal URL appended. An invalid key is + not retriable and the message has to say what to do about it. + * 402 → ``PaymentError``. Same class the wallet rail raises when it + cannot pay, so the 402 the proxy already returns keeps working — only + the remedy differs (add credit, not fund a wallet). + """ + if status < 400: + return + message = _error_message(status, body) + if status == 429: + # The account API answers a rate limit with Retry-After, and every stock + # OpenAI/Anthropic client knows how to schedule off that header. Dropping + # it turns a client that would have waited into one that spins, so it is + # carried on the exception for the proxy to put back on the wire. + retry_after = None + if headers is not None: + try: + retry_after = headers.get("retry-after") + except Exception: # noqa: BLE001 - a header bag we cannot read is not fatal + retry_after = None + error = APIError(message, status) + error.retry_after = retry_after # type: ignore[attr-defined] + raise error + if status == 402: + raise PaymentError( + f"{message}. Add credit at {PORTAL_URL}/dashboard/billing.", + status_code=status, + response={"raw": message}, + ) + if status in (401, 403): + raise APIError( + f"{message}. Check BLOCKRUN_API_KEY, or issue a new key at " + f"{PORTAL_URL}/dashboard/keys.", + status, + ) + raise APIError(message, status) + + +# --------------------------------------------------------------------------- +# JSON calls +# --------------------------------------------------------------------------- + + +def post_json( + path: str, + body: Dict[str, Any], + *, + api_key: str, + timeout: Optional[float] = None, +) -> Dict[str, Any]: + """POST JSON, return the parsed response. Raises on non-2xx.""" + resp = sync_http().post( + target_url(path), json=body, headers=headers(api_key), timeout=_timeout(timeout) + ) + raise_for_status(resp.status_code, resp.content, resp.headers) + return resp.json() + + +async def apost_json( + path: str, + body: Dict[str, Any], + *, + api_key: str, + timeout: Optional[float] = None, +) -> Dict[str, Any]: + """Async :func:`post_json`.""" + resp = await async_http().post( + target_url(path), json=body, headers=headers(api_key), timeout=_timeout(timeout) + ) + raise_for_status(resp.status_code, resp.content, resp.headers) + return resp.json() + + +def get_json(path: str, *, api_key: str, timeout: Optional[float] = None) -> Dict[str, Any]: + resp = sync_http().get( + target_url(path), headers=headers(api_key), timeout=_timeout(timeout) + ) + raise_for_status(resp.status_code, resp.content, resp.headers) + return resp.json() + + +# --------------------------------------------------------------------------- +# Streaming +# --------------------------------------------------------------------------- + + +def _sse_payloads(line: str) -> Optional[Dict[str, Any]]: + """One SSE ``data:`` line to a chunk dict, or ``None`` to skip it. + + ``[DONE]``, comments and blank lines are structure, not data. Malformed + JSON is dropped with a warning rather than killing the stream: a single bad + frame must not lose the completion the caller has already paid for. + """ + if not line.startswith("data:"): + return None + payload = line[len("data:") :].strip() + if not payload or payload == "[DONE]": + return None + try: + parsed = json.loads(payload) + except ValueError: + _log.warning("dropping unparseable SSE frame from the BlockRun API") + return None + return parsed if isinstance(parsed, dict) else None + + +def _as_chunk(parsed: Dict[str, Any]) -> Optional[ChatCompletionChunk]: + """Validate a chunk dict into the SDK's type, keeping native extras. + + ``ChatCompletionChunk`` is configured ``extra="allow"``, so + ``system_fingerprint`` / ``service_tier`` / ``*_tokens_details`` survive — + which is the whole point of the gateway forwarding the upstream body + verbatim. A frame that fails validation is skipped for the same reason a + malformed one is: partial output beats no output. + """ + try: + return ChatCompletionChunk.model_validate(parsed) + except Exception: # noqa: BLE001 - pydantic raises its own error type + _log.warning("dropping non-conforming chat chunk from the BlockRun API") + return None + + +def stream_chat( + body: Dict[str, Any], + *, + api_key: str, + timeout: Optional[float] = None, +) -> Iterator[ChatCompletionChunk]: + """Stream ``/v1/chat/completions``, yielding SDK-typed chunks. + + The upstream status is checked *before* any frame is yielded — a 402 or a + 429 arrives as an ordinary JSON error body with a non-200 status, and + turning that into an empty stream would hide it from the caller. + """ + payload = dict(body) + payload["stream"] = True + with sync_http().stream( + "POST", + target_url("/v1/chat/completions"), + json=payload, + headers=headers(api_key, {"Accept": "text/event-stream"}), + timeout=_timeout(timeout), + ) as resp: + if resp.status_code >= 400: + raise_for_status(resp.status_code, resp.read(), resp.headers) + for line in resp.iter_lines(): + parsed = _sse_payloads(line) + if parsed is None: + continue + chunk = _as_chunk(parsed) + if chunk is not None: + yield chunk + + +async def astream_chat( + body: Dict[str, Any], + *, + api_key: str, + timeout: Optional[float] = None, +) -> AsyncIterator[ChatCompletionChunk]: + """Async :func:`stream_chat`.""" + payload = dict(body) + payload["stream"] = True + async with async_http().stream( + "POST", + target_url("/v1/chat/completions"), + json=payload, + headers=headers(api_key, {"Accept": "text/event-stream"}), + timeout=_timeout(timeout), + ) as resp: + if resp.status_code >= 400: + raise_for_status(resp.status_code, await resp.aread(), resp.headers) + async for line in resp.aiter_lines(): + parsed = _sse_payloads(line) + if parsed is None: + continue + chunk = _as_chunk(parsed) + if chunk is not None: + yield chunk + + +# --------------------------------------------------------------------------- +# Video: submit + poll +# --------------------------------------------------------------------------- +# Video is the one medium that is not a single request. The gateway answers a +# submit with a job id and generates asynchronously, so the SDK's VideoClient +# submits then polls to completion, and its callers — including this package's +# proxy, which layers the OpenAI Videos job API on top — expect a *finished* +# video back. This reproduces that loop, minus the x402 half: no 402 probe, no +# signature to re-sign when the 600s authorization window lapses. Statuses and +# the give-up behaviour deliberately match the SDK's so the two rails fail the +# same way. + +_POLL_INTERVAL_S = 5.0 +_DEFAULT_VIDEO_BUDGET_S = 900.0 + + +def submit_and_poll_video( + body: Dict[str, Any], + *, + api_key: str, + budget_seconds: Optional[float] = None, + sleep: Any = None, +) -> Dict[str, Any]: + """Submit a video job and poll until it finishes. Returns the final job. + + ``sleep`` is injectable so tests do not spend real seconds waiting. + """ + nap = sleep or time.sleep + submitted = post_json("/v1/videos/generations", body, api_key=api_key, timeout=120.0) + job_id = submitted.get("id") + if not job_id: + raise APIError("Video submit response carried no job id", 502, {"response": submitted}) + + status = submitted.get("status", "queued") + if status == "completed": + return submitted + + deadline = time.monotonic() + (budget_seconds or _DEFAULT_VIDEO_BUDGET_S) + while time.monotonic() < deadline: + nap(_POLL_INTERVAL_S) + resp = sync_http().get( + target_url(f"/v1/videos/{job_id}"), headers=headers(api_key), timeout=_timeout(60.0) + ) + try: + poll = resp.json() + except ValueError: + poll = {} + status = poll.get("status", status) + if status == "completed": + return poll + if status == "failed": + raise APIError( + f"Upstream generation failed: {poll.get('error', 'unknown')}", + resp.status_code, + poll, + ) + # 504 is a transient upstream hiccup on the gateway's own poll; 202 is + # "still working". Anything else is a real failure and is raised. + if resp.status_code not in (200, 202, 504): + raise_for_status(resp.status_code, resp.content, resp.headers) + + raise APIError( + f"Video generation did not complete within the {budget_seconds or _DEFAULT_VIDEO_BUDGET_S:.0f}s " + f"budget (last status: {status}). The job is not lost — it stays claimable for " + f"~48h; GET /v1/videos/{job_id} to fetch it once it finishes.", + 504, + {"id": job_id, "last_status": status}, + ) + + +# --------------------------------------------------------------------------- +# Raw passthrough (used by the proxy for /v1/messages and friends) +# --------------------------------------------------------------------------- + + +def passthrough_headers(inbound: Dict[str, str], api_key: str) -> Dict[str, str]: + """Headers for a byte-for-byte forward. + + The client's own ``Authorization`` is *replaced*, never forwarded: on this + rail that header carried the proxy's optional shared secret + (``BLOCKRUN_PROXY_TOKEN``), and leaking a local secret to a remote host + would be a worse bug than the one it guards against. + """ + out = {k: v for k, v in inbound.items() if k.lower() not in ("authorization", "host")} + out["Authorization"] = f"Bearer {api_key}" + return out + + +def open_stream( + method: str, + path: str, + content: bytes, + hdrs: Dict[str, str], + *, + timeout: Optional[float] = None, +) -> Tuple[httpx.Response, Any]: + """Open an un-read streaming response, mirroring the wallet rail's helper. + + Returns ``(response, context)``; the caller must close the context. Split + this way so the caller can inspect ``status_code`` before deciding whether + to stream a body or surface an error. + """ + ctx = sync_http().stream( + method, target_url(path), content=content, headers=hdrs, timeout=_timeout(timeout) + ) + return ctx.__enter__(), ctx + + +__all__ = [ + "DEFAULT_API_BASE", + "KEY_PREFIX", + "PORTAL_URL", + "UNSUPPORTED_PATHS", + "api_base", + "apost_json", + "astream_chat", + "async_http", + "get_json", + "headers", + "looks_like_api_key", + "open_stream", + "passthrough_headers", + "post_json", + "raise_for_status", + "resolve_api_key", + "stream_chat", + "submit_and_poll_video", + "sync_http", + "target_url", + "unsupported_reason", +] diff --git a/blockrun_litellm/logger.py b/blockrun_litellm/logger.py index b29e30b..d32a45d 100644 --- a/blockrun_litellm/logger.py +++ b/blockrun_litellm/logger.py @@ -48,7 +48,10 @@ (``cost_source == "blockrun_x402"``); otherwise LiteLLM's token×list-price estimate. ``0.0`` for free models, ``None`` on failure. - cost_source — "blockrun_x402" (real on-chain charge) or + cost_source — "blockrun_x402" (real on-chain charge), "blockrun_account" + (billed to prepaid credit via an API key — no per-call + on-chain charge exists; the ledger at user.blockrun.ai is + authoritative and ``cost_usd`` is only an estimate), or "litellm_estimate" (fallback token×list-price guess) estimated_cost_usd — LiteLLM's token×list-price estimate, always recorded alongside so the estimate vs real gap is auditable @@ -158,23 +161,65 @@ def _extract_cost(response_obj: Any, kwargs: Dict[str, Any]) -> Optional[float]: def _extract_real_cost(response_obj: Any) -> Dict[str, Any]: - """Pull BlockRun's real x402 charge + settlement off the response. + """Pull BlockRun's real x402 charge, settlement and rail off the response. - Returns ``{"cost_usd": , "settlement": }``. When a - real charge is present we record it as the authoritative ``cost_usd`` and - tag ``cost_source="blockrun_x402"``; otherwise the row falls back to - LiteLLM's token×list-price estimate (``cost_source="litellm_estimate"``). + Returns ``{"cost_usd": , "settlement": , + "rail": <"api_key"|None>}``. When a real charge is present we record it as + the authoritative ``cost_usd`` and tag ``cost_source="blockrun_x402"``; + see :func:`_cost_fields` for what the other two cases mean. """ try: - hp = _hidden_params(response_obj) - if hp and hp.get("blockrun_cost_usd") is not None: + hp = _hidden_params(response_obj) or {} + rail = hp.get("blockrun_rail") + if hp.get("blockrun_cost_usd") is not None: return { "cost_usd": float(hp["blockrun_cost_usd"]), "settlement": hp.get("blockrun_settlement"), + "rail": rail, } + if rail: + return {"cost_usd": None, "settlement": None, "rail": rail} except Exception: pass - return {"cost_usd": None, "settlement": None} + return {"cost_usd": None, "settlement": None, "rail": None} + + +def _process_rail() -> Optional[str]: + """The rail this process is configured for, when the response didn't say. + + Streaming is why this exists: the per-call marker rides on the assembled + response's ``_hidden_params``, and on the account rail there is no charge to + hang it off, so nothing puts it there. The sidecar and a + ``BLOCKRUN_API_KEY``-configured library process have exactly one rail for + their whole lifetime, which makes the env an honest fallback rather than a + guess. + """ + from blockrun_litellm import _apikey + + return "api_key" if _apikey.resolve_api_key() else None + + +def _cost_fields(real: Dict[str, Any], estimate: Optional[float]) -> Dict[str, Any]: + """Resolve ``cost_usd`` + ``cost_source`` from what the call actually knows. + + Three outcomes, and the difference between the last two is the point: + + * ``blockrun_x402`` — ``cost_usd`` IS the settled on-chain charge. + * ``blockrun_account`` — the call was billed to prepaid account credit, so + there is no per-call on-chain charge and never will be. ``cost_usd`` here + is LiteLLM's token x list-price estimate (``None`` when LiteLLM has no + price for the model); the authoritative figure is the account ledger at + user.blockrun.ai. Tagging these ``litellm_estimate`` would say "we fell + back to a guess", implying a real number existed and was missed — it did + not, and a reconciliation job needs to be able to tell those apart. + * ``litellm_estimate`` — the wallet rail did not report a charge (older SDK, + free/cached call), so the estimate is standing in for one. + """ + if real["cost_usd"] is not None: + return {"cost_usd": real["cost_usd"], "cost_source": "blockrun_x402"} + if (real.get("rail") or _process_rail()) == "api_key": + return {"cost_usd": estimate, "cost_source": "blockrun_account"} + return {"cost_usd": estimate, "cost_source": "litellm_estimate"} def _latency_ms(start_time: Any, end_time: Any) -> Optional[float]: @@ -254,19 +299,14 @@ def _build_entry( return None estimate = _extract_cost(response_obj, kwargs) real = _extract_real_cost(response_obj) - if real["cost_usd"] is not None: - cost_usd = real["cost_usd"] - cost_source = "blockrun_x402" - else: - cost_usd = estimate - cost_source = "litellm_estimate" + cost = _cost_fields(real, estimate) entry.update({ "status": "success", "completion": completion, "usage": usage, # Real wallet deduction when known (x402), else LiteLLM's estimate. - "cost_usd": cost_usd, - "cost_source": cost_source, + "cost_usd": cost["cost_usd"], + "cost_source": cost["cost_source"], # Keep LiteLLM's token×list-price estimate alongside for comparison. "estimated_cost_usd": estimate, "settlement": real["settlement"], @@ -324,7 +364,11 @@ def log_proxy_call( "status": "success" if (http_status or 0) < 400 else "failure", "http_status": http_status, "cost_usd": cost_usd, - "cost_source": "blockrun_x402" if cost_usd is not None else None, + "cost_source": ( + "blockrun_x402" + if cost_usd is not None + else ("blockrun_account" if _process_rail() == "api_key" else None) + ), "settlement": settlement, "request_id": request_id, } diff --git a/blockrun_litellm/provider.py b/blockrun_litellm/provider.py index 7a8135d..2e7b901 100644 --- a/blockrun_litellm/provider.py +++ b/blockrun_litellm/provider.py @@ -169,13 +169,20 @@ def _attach_real_cost(response: litellm.ModelResponse, meta: Optional[Dict[str, """ if not meta: return - cost = meta.get("cost_usd") - if cost is None: - return hidden = getattr(response, "_hidden_params", None) if not isinstance(hidden, dict): hidden = {} response._hidden_params = hidden + # Which rail served the call, recorded whether or not a charge came with + # it. On the account rail there is no per-call charge to attach, and that + # absence is exactly what the audit row needs to be able to say — without + # the marker it is indistinguishable from "the SDK didn't report one", + # which is a different thing and points at a different remedy. + if meta.get("rail"): + hidden["blockrun_rail"] = meta["rail"] + cost = meta.get("cost_usd") + if cost is None: + return cost = float(cost) hidden["response_cost"] = cost hidden["blockrun_cost_usd"] = cost diff --git a/blockrun_litellm/proxy.py b/blockrun_litellm/proxy.py index 7d9e879..b57c881 100644 --- a/blockrun_litellm/proxy.py +++ b/blockrun_litellm/proxy.py @@ -72,7 +72,7 @@ from blockrun_llm.types import APIError, PaymentError from blockrun_llm.tx_log import decode_settlement_header -from blockrun_litellm import _adapter +from blockrun_litellm import _adapter, _apikey from blockrun_litellm import logger as _logger # Optional — present when blockrun-llm[solana] is installed alongside solana-py. @@ -183,6 +183,16 @@ def healthz() -> Dict[str, str]: @app.get("/v1/models", dependencies=[Depends(_require_token)]) async def list_models() -> Dict[str, Any]: + key = _proxy_api_key() + if key: + # The account API republishes the same catalogue, already in OpenAI + # list shape. Fetching it with the key (rather than anonymously from + # the gateway) keeps one upstream per rail — and this route must not + # build a wallet client, which is what the SDK path below does even + # though listing models costs nothing. + return await run_in_threadpool( + lambda: _apikey.get_json("/v1/models", api_key=key, timeout=60.0) + ) # Reuse the cached async client; ``list_models`` does not require payment. client = _adapter.get_async_client() models = await client.list_models() @@ -326,13 +336,37 @@ def close(self) -> None: self._base.close() +def _proxy_api_key() -> Optional[str]: + """The BlockRun API key this sidecar serves with, if it has one. + + Read per call rather than cached at import: ``main()`` may set + ``BLOCKRUN_API_KEY`` from ``--api-key`` after this module is imported, and + tests flip it between cases. + """ + return _apikey.resolve_api_key() + + def _resolve_api_url() -> str: - return (os.environ.get("BLOCKRUN_API_URL") or _adapter.BASE_API_URL).rstrip("/") + """Upstream base URL for the passthrough routes, for whichever rail is on. + + With a key that is the account API (which has no chain and no ``/api`` + prefix); without one it is the gateway on the configured chain. + """ + if _proxy_api_key(): + return _apikey.api_base() + return _adapter.resolve_api_url().rstrip("/") def _messages_client(api_url: str) -> httpx.Client: """Cached httpx client whose transport signs x402 for any path on the chain - implied by ``api_url`` (Base via EIP-712, Solana via SVM).""" + implied by ``api_url`` (Base via EIP-712, Solana via SVM). + + On the API-key rail there is nothing to sign, so this is the plain pooled + client — the Bearer header is added per request by the caller. + """ + if _proxy_api_key(): + return _apikey.sync_http() + existing = _messages_http_clients.get(api_url) if existing is not None: return existing @@ -521,6 +555,24 @@ def _cost_response_headers( return out +def _retry_after(source: Any) -> Dict[str, str]: + """Carry an upstream ``Retry-After`` through, when there is one. + + Every stock OpenAI and Anthropic client schedules its own backoff off this + header. Dropping it on the way through turns a client that would have + waited into one that spins against a rate limit — so a 429 that arrives + with a wait time must leave with it. + """ + value = None + if source is not None: + try: + value = source.get("retry-after") if hasattr(source, "get") else None + except Exception: # noqa: BLE001 - an unreadable header bag is not fatal + value = None + value = value or getattr(source, "retry_after", None) + return {"retry-after": str(value)} if value else {} + + def _body_model(raw: bytes) -> Optional[str]: try: return _json.loads(raw or b"{}").get("model") @@ -563,11 +615,47 @@ async def _forward_passthrough( forwarding (Gemini strips ``?key=``/``?alt=sse``; the gateway re-derives both server-side). """ + api_key = _proxy_api_key() + if api_key: + unsupported = _apikey.unsupported_reason(path) + if unsupported: + # Logged like any other exit, even though it is free and local: the + # 0.7.6 invariant is that every exit leaves a row, and a refusal + # nobody can see in the audit trail is a support ticket with no + # evidence behind it. + _logger.log_proxy_call( + model=model_override or _body_model(await request.body()), + path=path, + stream=False, + http_status=501, + cost_usd=None, + settlement=None, + latency_ms=0.0, + request_id=request.headers.get("x-request-id"), + ) + return JSONResponse( + status_code=501, + content={ + "error": { + "message": unsupported, + "type": "invalid_request_error", + "code": "unsupported_on_api_key", + } + }, + ) + api_url = _resolve_api_url() raw = await request.body() client = _messages_client(api_url) qs = request.url.query if forward_query else "" - target = f"{api_url}{path}" + (f"?{qs}" if qs else "") + if api_key: + # target_url applies the account API's path spellings (e.g. the OpenAI + # /v1/images/edits alias) — the gateway path is not always the one the + # account API publishes. + target = _apikey.target_url(path) + (f"?{qs}" if qs else "") + headers = _apikey.passthrough_headers(headers, api_key) + else: + target = f"{api_url}{path}" + (f"?{qs}" if qs else "") _t0 = time.monotonic() model = model_override or _body_model(raw) @@ -671,11 +759,12 @@ def _post(): resp.content, cost, settlement, + _retry_after(resp.headers), ) async with _get_semaphore(): try: - status, ctype, content, cost, settlement = await run_in_threadpool(_post) + status, ctype, content, cost, settlement, retry = await run_in_threadpool(_post) except Exception as exc: # noqa: BLE001 if _is_solana_rpc_exc(exc): log.warning("solana rpc error during payment signing: %s", _solana_rpc_msg(exc)) @@ -699,7 +788,7 @@ def _post(): content=content, status_code=status, media_type=ctype, - headers=_cost_response_headers(cost, settlement), + headers={**_cost_response_headers(cost, settlement), **retry}, ) @@ -944,10 +1033,16 @@ async def _media_endpoint( result: Optional[Dict[str, Any]] = None parse_failed_after_settlement = False reached_gateway = True + retry_headers: Dict[str, str] = {} # Snapshot the chain NOW, not at log time: BLOCKRUN_API_URL is a mutable # global and these calls run for minutes. If it flipped mid-flight we would # classify a Solana charge as Base and write it off as free. - is_solana = _adapter._is_solana_url(None) + # The API-key rail is folded in under the same flag rather than given its + # own: the flag means "a post-gateway failure may still have moved money", + # and that is exactly true of a metered account call — the account is + # debited from the upstream usage the moment the gateway answers, whatever + # happens on the way back here. + is_solana = _adapter._is_solana_url(None) or bool(_proxy_api_key()) async with _get_media_semaphore(): try: result = await call() @@ -979,6 +1074,7 @@ async def _media_endpoint( except APIError as exc: status = exc.status_code if 400 <= getattr(exc, "status_code", 0) < 600 else 502 payload = {"error": str(exc)} + retry_headers = _retry_after(exc) except Exception as exc: # noqa: BLE001 - a missing row is worse than a broad catch # Transport errors (httpx.ReadTimeout on a 10-minute image call, # connection resets) escape the SDK unwrapped. If one lands after the @@ -1011,6 +1107,7 @@ async def _media_endpoint( ), ) headers = _cost_response_headers(None, settlement) + headers.update(retry_headers) if warning: headers[_WARNING_HEADER] = warning return JSONResponse(status_code=status, content=payload, headers=headers) @@ -1456,7 +1553,12 @@ async def _run_video_job(job: Dict[str, Any], prompt: str, kwargs: Dict[str, Any # Snapshot the chain NOW, not at log time: BLOCKRUN_API_URL is a mutable # global and these calls run for minutes. If it flipped mid-flight we would # classify a Solana charge as Base and write it off as free. - is_solana = _adapter._is_solana_url(None) + # The API-key rail is folded in under the same flag rather than given its + # own: the flag means "a post-gateway failure may still have moved money", + # and that is exactly true of a metered account call — the account is + # debited from the upstream usage the moment the gateway answers, whatever + # happens on the way back here. + is_solana = _adapter._is_solana_url(None) or bool(_proxy_api_key()) async with _get_media_semaphore(): job["status"] = "in_progress" try: @@ -2039,10 +2141,25 @@ def main() -> None: ) parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)") parser.add_argument("--port", type=int, default=4001, help="Bind port (default: 4001)") + parser.add_argument( + "--api-key", + default=None, + help=( + "BlockRun API key (brk_live_...). Pays from prepaid account credit " + "instead of an x402 wallet; no chain involved. Get one at " + f"{_apikey.PORTAL_URL}. Env: BLOCKRUN_API_KEY" + ), + ) + parser.add_argument( + "--chain", + default=None, + choices=["solana", "base"], + help="Wallet-rail chain (default: solana). Ignored when --api-key is set.", + ) parser.add_argument( "--api-url", default=None, - help="Override BlockRun API URL (default: https://blockrun.ai/api)", + help="Override the BlockRun gateway URL (default: the --chain gateway)", ) parser.add_argument( "--log-level", @@ -2051,14 +2168,41 @@ def main() -> None: ) args = parser.parse_args() + if args.api_key: + os.environ["BLOCKRUN_API_KEY"] = args.api_key + if args.chain: + os.environ["BLOCKRUN_CHAIN"] = args.chain if args.api_url: os.environ["BLOCKRUN_API_URL"] = args.api_url - # Fail fast if no wallet — better than waiting for first request. - try: - _adapter.get_sync_client() - except ValueError as exc: - parser.exit(2, f"\nWallet not configured:\n {exc}\n") + # Fail fast on a credential problem — better than waiting for first request. + # + # The two rails fail differently and the check has to match the one that is + # actually on, or the API-key path would be blocked by a wallet requirement + # it does not have. A key is only shape-checked here: proving it works means + # a network round trip, and a sidecar that will not boot because the account + # API is briefly unreachable is worse than one that answers 401 per request. + key = _apikey.resolve_api_key() + if key: + if not _apikey.looks_like_api_key(key): + parser.exit( + 2, + f"\nBLOCKRUN_API_KEY does not look like a BlockRun key " + f"(expected a {_apikey.KEY_PREFIX}... prefix).\n", + ) + log.info("serving on the API-key rail via %s", _apikey.api_base()) + else: + try: + _adapter.get_sync_client() + except ValueError as exc: + parser.exit( + 2, + f"\nNo credential configured:\n {exc}\n\n" + f"Either set a wallet key, or use a BlockRun API key:\n" + f" blockrun-litellm-proxy --api-key brk_live_...\n" + f" (get one at {_apikey.PORTAL_URL})\n", + ) + log.info("serving on the x402 wallet rail via %s", _adapter.resolve_api_url()) import uvicorn diff --git a/examples/litellm_config.yaml b/examples/litellm_config.yaml index 6624207..4f854d2 100644 --- a/examples/litellm_config.yaml +++ b/examples/litellm_config.yaml @@ -1,7 +1,9 @@ # LiteLLM Proxy Server config — routes through the blockrun-litellm sidecar. # -# 1) Start the sidecar: -# export BLOCKRUN_WALLET_KEY=0xYOUR_KEY +# 1) Start the sidecar with ONE credential: +# blockrun-litellm-proxy --port 4001 --api-key brk_live_... # account credit +# or, to pay per call from an x402 wallet (Solana is the default chain): +# export SOLANA_WALLET_KEY=YOUR_SOLANA_PRIVATE_KEY # blockrun-litellm-proxy --port 4001 # # 2) Start LiteLLM Proxy with this file: diff --git a/examples/python_lib.py b/examples/python_lib.py index 319feb6..697b016 100644 --- a/examples/python_lib.py +++ b/examples/python_lib.py @@ -3,7 +3,11 @@ Prereqs: pip install blockrun-litellm - export BLOCKRUN_WALLET_KEY=0xYOUR_BASE_CHAIN_PRIVATE_KEY + + # Pick ONE credential. + export BLOCKRUN_API_KEY=brk_live_... # account credit (https://user.blockrun.ai) + export SOLANA_WALLET_KEY=YOUR_SOLANA_KEY # or x402 on Solana (default chain) + export BLOCKRUN_WALLET_KEY=0xYOUR_BASE_KEY # or x402 on Base (BLOCKRUN_CHAIN=base) Run: python examples/python_lib.py diff --git a/examples/raw_openai_sdk.py b/examples/raw_openai_sdk.py index dfee3a2..f769cee 100644 --- a/examples/raw_openai_sdk.py +++ b/examples/raw_openai_sdk.py @@ -3,9 +3,11 @@ Prereqs: pip install 'blockrun-litellm[proxy]' openai - export BLOCKRUN_WALLET_KEY=0xYOUR_BASE_CHAIN_PRIVATE_KEY -Start the sidecar in another terminal: +Start the sidecar in another terminal, with ONE credential: + blockrun-litellm-proxy --port 4001 --api-key brk_live_... # account credit + # or: + export SOLANA_WALLET_KEY=YOUR_SOLANA_KEY # x402, Solana (default) blockrun-litellm-proxy --port 4001 Then: diff --git a/pyproject.toml b/pyproject.toml index d159035..02bbfbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,15 +4,15 @@ build-backend = "hatchling.build" [project] name = "blockrun-litellm" -version = "0.9.1" -description = "LiteLLM adapter for BlockRun — call x402-paid AI models via LiteLLM (custom provider or local OpenAI-compatible proxy)" +version = "0.10.0" +description = "LiteLLM adapter for BlockRun — call 90+ AI models via LiteLLM with a BlockRun API key or an x402 USDC wallet (Solana or Base)" readme = "README.md" license = "MIT" requires-python = ">=3.9" authors = [ { name = "BlockRun", email = "hello@blockrun.ai" } ] -keywords = ["litellm", "blockrun", "x402", "openai", "llm-proxy", "openrouter", "ai-gateway"] +keywords = ["litellm", "blockrun", "x402", "openai", "llm-proxy", "openrouter", "ai-gateway", "solana", "api-key"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", diff --git a/tests/conftest.py b/tests/conftest.py index 6b17c33..339496a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,8 @@ from blockrun_llm.types import ChatChoice, ChatMessage, ChatResponse, ChatUsage +from blockrun_litellm import _adapter, _apikey + # --------------------------------------------------------------------------- # Canned response builder @@ -56,10 +58,36 @@ def make_chat_response( # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) -def _no_wallet_required(monkeypatch: pytest.MonkeyPatch) -> None: - """Make sure tests run without `BLOCKRUN_WALLET_KEY` set.""" - monkeypatch.delenv("BLOCKRUN_WALLET_KEY", raising=False) - monkeypatch.delenv("BASE_CHAIN_WALLET_KEY", raising=False) +def _no_wallet_required(monkeypatch: pytest.MonkeyPatch, tmp_path_factory) -> None: + """Run every test on a host with no credentials and no chain preference. + + Since 0.10.0 the implicit chain default reads ``~/.blockrun`` twice — for a + chain the CLI recorded, and for which wallets exist. Left alone, the suite + would resolve a different default on a developer's laptop than in CI, which + is exactly the kind of test that passes everywhere except where it matters. + (It bit immediately: this machine has ``~/.blockrun/.chain`` set to "base".) + So both the session files and the chain files are pointed at a directory + that does not exist, the credential env vars are cleared, and the memoized + answer is dropped: every test starts on the documented default (Solana) + unless it says otherwise. + """ + for name in ( + "BLOCKRUN_WALLET_KEY", + "BASE_CHAIN_WALLET_KEY", + "SOLANA_WALLET_KEY", + "BLOCKRUN_API_KEY", + "BLOCKRUN_CHAIN", + "BLOCKRUN_API_BASE_URL", + ): + monkeypatch.delenv(name, raising=False) + absent = tmp_path_factory.mktemp("no-wallets") / "nowhere" + monkeypatch.setattr(_adapter, "_SOLANA_SESSION", absent / ".solana-session") + monkeypatch.setattr(_adapter, "_BASE_SESSION", absent / ".session") + monkeypatch.setattr( + _adapter, "_CHAIN_FILES", (absent / "payment-chain", absent / ".chain") + ) + _adapter._reset_chain_cache_for_tests() + _apikey._reset_clients_for_tests() @pytest.fixture diff --git a/tests/test_adapter_solana.py b/tests/test_adapter_solana.py index 5893d82..88e0623 100644 --- a/tests/test_adapter_solana.py +++ b/tests/test_adapter_solana.py @@ -34,8 +34,18 @@ def test_is_solana_url_recognizes_gateway(): def test_is_solana_url_rejects_base(): assert _is_solana_url("https://blockrun.ai/api") is False - assert _is_solana_url(None) is False - assert _is_solana_url("") is False + + +def test_is_solana_url_defaults_to_solana_when_nothing_is_configured(): + """An unconfigured host is a Solana host as of 0.10.0. + + ``None`` and ``""`` both mean "no explicit URL", and both used to resolve + to Base by falling through to the SDK's own default. Pinning the new answer + here is the point: this is the behaviour change, and it must not be able to + revert quietly. + """ + assert _is_solana_url(None) is True + assert _is_solana_url("") is True def test_filter_kwargs_keeps_tools_on_both_chains(): diff --git a/tests/test_api_key_rail.py b/tests/test_api_key_rail.py new file mode 100644 index 0000000..2c3e8ee --- /dev/null +++ b/tests/test_api_key_rail.py @@ -0,0 +1,685 @@ +"""The API-key rail: a BlockRun account instead of an x402 wallet. + +Two things are being pinned. First, that a key routes every surface at +``api.blockrun.ai`` with a Bearer header and no wallet anywhere in the path — +including the surfaces whose gateway spelling differs from the account API's. +Second, that the *absence* of on-chain cost is reported as absence: the wallet +rail's headline feature is the exact settled charge, and an estimate wearing +that label would be worse than no number at all. + +Nothing here touches the network. The pooled httpx clients are replaced with +``httpx.MockTransport``, which is also what proves the requests carry the +headers and URLs claimed above. +""" + +from __future__ import annotations + +import asyncio +import json + +import httpx +import pytest +from fastapi.testclient import TestClient + +import blockrun_litellm.proxy as proxy +from blockrun_litellm import _adapter, _apikey +from blockrun_llm.types import APIError, PaymentError + +KEY = "brk_live_TESTKEY0123456789" + +CHAT_BODY = { + "id": "chatcmpl-acct-1", + "object": "chat.completion", + "created": 1_700_000_000, + "model": "gpt-5.5-2026-04-23", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4}, + "system_fingerprint": "fp_acct", +} + + +class Recorder: + """A MockTransport that records every request it answers.""" + + def __init__(self, responder): + self.requests: list[httpx.Request] = [] + self._responder = responder + + def handle(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + return self._responder(request) + + @property + def last(self) -> httpx.Request: + assert self.requests, "no request was made" + return self.requests[-1] + + +@pytest.fixture +def transport(monkeypatch): + """Route both pooled clients through one recorder. + + Returns a factory: call it with a responder to install the behaviour for + the test, and read ``.requests`` off the result. + """ + + def install(responder): + rec = Recorder(responder) + mock = httpx.MockTransport(rec.handle) + sync = httpx.Client(transport=mock) + aio = httpx.AsyncClient(transport=mock) + monkeypatch.setattr(_apikey, "sync_http", lambda: sync) + monkeypatch.setattr(_apikey, "async_http", lambda: aio) + return rec + + return install + + +def json_responder(payload, status=200, headers=None): + def _respond(_request): + return httpx.Response(status, json=payload, headers=headers or {}) + + return _respond + + +# --------------------------------------------------------------------------- +# Credential resolution +# --------------------------------------------------------------------------- + + +class TestResolution: + def test_env_key_is_picked_up(self, monkeypatch): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + assert _apikey.resolve_api_key() == KEY + + def test_explicit_key_beats_env(self, monkeypatch): + monkeypatch.setenv("BLOCKRUN_API_KEY", "brk_live_from_env") + assert _apikey.resolve_api_key(KEY) == KEY + + @pytest.mark.parametrize( + "wallet_key", + [ + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", # Base hex + "5JGiBQ8m1nP1cWQ1kR5nJTaHqZ1YvWc8Vc1sQ3sQ3sQ3sQ3sQ3sQ", # Solana base58 + None, + "", + ], + ) + def test_a_wallet_key_is_not_an_api_key(self, wallet_key): + """The whole point of the prefix test: one argument, two credentials. + + LiteLLM's ``api_key`` lands in the adapter's ``private_key``. If a + wallet key could be mistaken for an account key, every existing wallet + caller would silently start authenticating against the account API with + their private key in a header — which is the worst failure this package + could have. + """ + assert _apikey.looks_like_api_key(wallet_key) is False + assert _apikey.resolve_api_key(wallet_key) is None + + def test_wallet_key_does_not_shadow_the_env_key(self, monkeypatch): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + assert _apikey.resolve_api_key("0xdeadbeef") == KEY + + @pytest.mark.parametrize("bad", ["brk_", "brk_live_ abc", "brk_live_\tx"]) + def test_a_mangled_key_is_an_error_not_a_fallback(self, monkeypatch, bad): + """Falling through to the wallet rail on a typo is the wrong failure. + + The caller asked for the account rail. Quietly using a wallet instead + either 402s for one they do not have or spends from one they did not + mean to touch — both worse than being told the key is malformed. + """ + monkeypatch.setenv("BLOCKRUN_API_KEY", bad) + with pytest.raises(ValueError, match="BlockRun key|usable"): + _apikey.resolve_api_key() + + def test_a_non_brk_env_value_is_an_error(self, monkeypatch): + # Someone pasting a wallet key into BLOCKRUN_API_KEY must hear about it, + # not have it silently ignored while the rail they asked for is skipped. + monkeypatch.setenv("BLOCKRUN_API_KEY", "0xdeadbeef") + with pytest.raises(ValueError, match="does not start with"): + _apikey.resolve_api_key() + + def test_both_credentials_at_once_is_refused(self, monkeypatch): + """No reading of that call is obviously right, and they spend different + money — so it is refused rather than ranked.""" + with pytest.raises(ValueError, match="not both"): + _adapter.chat_completion_sync( + "openai/gpt-5.5", + [{"role": "user", "content": "hi"}], + api_key=KEY, + private_key="0xdeadbeef", + ) + + def test_no_credentials_means_the_wallet_rail(self): + assert _apikey.resolve_api_key() is None + + def test_base_url_is_overridable(self, monkeypatch): + monkeypatch.setenv("BLOCKRUN_API_BASE_URL", "https://staging.example/") + assert _apikey.api_base() == "https://staging.example" + assert _apikey.target_url("/v1/models") == "https://staging.example/v1/models" + + def test_images_edits_is_rewritten_to_the_published_spelling(self): + assert _apikey.target_url("/v1/images/edits").endswith("/v1/images/image2image") + + +# --------------------------------------------------------------------------- +# Chat +# --------------------------------------------------------------------------- + + +class TestChat: + def test_sync_chat_goes_to_the_account_api_with_a_bearer(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + rec = transport(json_responder(CHAT_BODY)) + + payload = _adapter.chat_completion_sync( + "openai/gpt-5.5", [{"role": "user", "content": "hi"}], max_tokens=8 + ) + + assert str(rec.last.url) == "https://api.blockrun.ai/v1/chat/completions" + assert rec.last.headers["authorization"] == f"Bearer {KEY}" + sent = json.loads(rec.last.content) + assert sent["model"] == "openai/gpt-5.5" + assert sent["max_tokens"] == 8 + assert payload["choices"][0]["message"]["content"] == "hi" + # Native passthrough survives the rail change. + assert payload["system_fingerprint"] == "fp_acct" + + def test_no_wallet_is_ever_constructed(self, monkeypatch, transport): + """A key must not reach any SDK client — those need a private key. + + Poisoning the constructors is the only assertion that actually proves + it; a passing chat call would otherwise look identical if the wallet + path happened to be reachable on the developer's machine. + """ + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + transport(json_responder(CHAT_BODY)) + + def _boom(*_a, **_kw): + raise AssertionError("the API-key rail must not build a wallet client") + + monkeypatch.setattr(_adapter, "get_sync_client", _boom) + monkeypatch.setattr(_adapter, "get_async_client", _boom) + monkeypatch.setattr(_adapter, "get_image_client", _boom) + + _adapter.chat_completion_sync("openai/gpt-5.5", [{"role": "user", "content": "hi"}]) + + def test_cost_is_absent_not_estimated(self, monkeypatch, transport): + """No on-chain charge exists on this rail, and none is invented. + + The rail marker rides alongside so the audit row can say *why* the cost + is missing — "billed to account credit", not "we failed to read it". + """ + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + transport(json_responder(CHAT_BODY)) + + payload = _adapter.chat_completion_sync( + "openai/gpt-5.5", [{"role": "user", "content": "hi"}] + ) + meta = payload[_adapter._BLOCKRUN_META_KEY] + assert meta == {"cost_usd": None, "settlement": None, "rail": "api_key"} + + def test_per_call_key_switches_rails_without_env(self, monkeypatch, transport): + """``litellm.completion(..., api_key="brk_live_...")`` must just work.""" + rec = transport(json_responder(CHAT_BODY)) + _adapter.chat_completion_sync( + "openai/gpt-5.5", [{"role": "user", "content": "hi"}], private_key=KEY + ) + assert rec.last.headers["authorization"] == f"Bearer {KEY}" + + def test_async_chat(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + rec = transport(json_responder(CHAT_BODY)) + payload = asyncio.run( + _adapter.chat_completion_async("openai/gpt-5.5", [{"role": "user", "content": "hi"}]) + ) + assert payload["id"] == "chatcmpl-acct-1" + assert rec.last.headers["authorization"] == f"Bearer {KEY}" + + +# --------------------------------------------------------------------------- +# Streaming +# --------------------------------------------------------------------------- + + +SSE = ( + b'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"m",' + b'"choices":[{"index":0,"delta":{"role":"assistant","content":"He"},"finish_reason":null}],' + b'"system_fingerprint":"fp_acct"}\n\n' + b'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"m",' + b'"choices":[{"index":0,"delta":{"content":"llo"},"finish_reason":"stop"}]}\n\n' + b"data: [DONE]\n\n" +) + + +class TestStreaming: + def test_sse_becomes_sdk_chunks(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + transport( + lambda _r: httpx.Response( + 200, content=SSE, headers={"content-type": "text/event-stream"} + ) + ) + chunks = list( + _adapter.chat_completion_stream_sync( + "openai/gpt-5.5", [{"role": "user", "content": "hi"}] + ) + ) + assert [c.choices[0].delta.content for c in chunks] == ["He", "llo"] + # extra="allow" keeps the relay-detection signals the gateway forwards. + assert chunks[0].model_extra["system_fingerprint"] == "fp_acct" + + def test_stream_asks_for_streaming(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + rec = transport( + lambda _r: httpx.Response( + 200, content=SSE, headers={"content-type": "text/event-stream"} + ) + ) + list( + _adapter.chat_completion_stream_sync( + "openai/gpt-5.5", [{"role": "user", "content": "hi"}] + ) + ) + assert json.loads(rec.last.content)["stream"] is True + + def test_a_bad_frame_does_not_kill_the_stream(self, monkeypatch, transport): + """The caller has already paid for the completion; drop the frame, not it.""" + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + broken = b"data: {not json}\n\n" + SSE + transport( + lambda _r: httpx.Response( + 200, content=broken, headers={"content-type": "text/event-stream"} + ) + ) + chunks = list( + _adapter.chat_completion_stream_sync( + "openai/gpt-5.5", [{"role": "user", "content": "hi"}] + ) + ) + assert [c.choices[0].delta.content for c in chunks] == ["He", "llo"] + + def test_an_error_status_raises_instead_of_yielding_nothing(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + transport( + json_responder( + {"error": {"message": "Rate limit exceeded", "code": "rate_limit_exceeded"}}, + status=429, + ) + ) + with pytest.raises(APIError) as excinfo: + list( + _adapter.chat_completion_stream_sync( + "openai/gpt-5.5", [{"role": "user", "content": "hi"}] + ) + ) + assert excinfo.value.status_code == 429 + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class TestErrors: + def test_402_is_a_payment_error_pointing_at_billing(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + transport( + json_responder( + { + "error": { + "message": "Balance exhausted — add credit to continue", + "type": "insufficient_quota", + "code": "quota_exceeded", + } + }, + status=402, + ) + ) + with pytest.raises(PaymentError) as excinfo: + _adapter.chat_completion_sync("openai/gpt-5.5", [{"role": "user", "content": "hi"}]) + assert "user.blockrun.ai" in str(excinfo.value) + + def test_401_names_the_portal(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + transport( + json_responder( + {"error": {"message": "Invalid API key", "code": "invalid_api_key"}}, status=401 + ) + ) + with pytest.raises(APIError) as excinfo: + _adapter.chat_completion_sync("openai/gpt-5.5", [{"role": "user", "content": "hi"}]) + assert "user.blockrun.ai" in str(excinfo.value) + assert excinfo.value.status_code == 401 + + def test_html_from_a_load_balancer_is_not_echoed_as_a_message(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + transport(lambda _r: httpx.Response(502, content=b"Bad Gateway")) + with pytest.raises(APIError) as excinfo: + _adapter.chat_completion_sync("openai/gpt-5.5", [{"role": "user", "content": "hi"}]) + assert "" not in str(excinfo.value) + assert "502" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# Media +# --------------------------------------------------------------------------- + + +IMAGE_BODY = {"created": 1, "data": [{"url": "https://cdn.example/a.png"}]} + + +class TestMedia: + def test_image_generation_sends_the_sdk_body(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + rec = transport(json_responder(IMAGE_BODY)) + result = _adapter.image_generation_sync("a cat") + assert result == IMAGE_BODY + assert str(rec.last.url) == "https://api.blockrun.ai/v1/images/generations" + sent = json.loads(rec.last.content) + # Defaults come off the SDK class, so the two rails send the same body + # for a caller who named neither model nor size. + assert sent == {"model": "google/nano-banana", "prompt": "a cat", "size": "1024x1024", "n": 1} + + def test_image_quality_is_forwarded_because_there_is_no_chain(self, monkeypatch, transport): + """The Solana-only rule is a property of the gateways, not of accounts.""" + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + rec = transport(json_responder(IMAGE_BODY)) + _adapter.image_generation_sync("a cat", model="openai/gpt-image-2", quality="high") + assert json.loads(rec.last.content)["quality"] == "high" + + def test_image_edit_uses_the_published_path(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + rec = transport(json_responder(IMAGE_BODY)) + _adapter.image_edit_sync("make it blue", "https://cdn.example/in.png") + assert str(rec.last.url) == "https://api.blockrun.ai/v1/images/image2image" + assert json.loads(rec.last.content)["image"] == "https://cdn.example/in.png" + + def test_speech(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + rec = transport(json_responder({"data": [{"url": "https://cdn.example/a.mp3"}]})) + asyncio.run(_adapter.speech_generation_async("hello", voice="george")) + assert str(rec.last.url) == "https://api.blockrun.ai/v1/audio/speech" + assert json.loads(rec.last.content)["voice"] == "george" + + def test_music_rejects_lyrics_with_instrumental_before_the_wire(self, monkeypatch, transport): + """The SDK raises this locally; the account rail must not lose the check + and let the gateway bill for a request it will refuse.""" + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + rec = transport(json_responder({})) + with pytest.raises(ValueError): + asyncio.run( + _adapter.music_generation_async("a song", instrumental=True, lyrics="la la") + ) + assert rec.requests == [] + + def test_video_submits_then_polls_to_completion(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + done = {"id": "job-1", "status": "completed", "data": [{"url": "https://cdn/x.mp4"}]} + states = [ + httpx.Response(200, json={"id": "job-1", "status": "queued"}), + httpx.Response(202, json={"id": "job-1", "status": "in_progress"}), + httpx.Response(200, json=done), + ] + transport(lambda _r: states.pop(0)) + monkeypatch.setattr(_apikey, "_POLL_INTERVAL_S", 0) + + result = asyncio.run(_adapter.video_generation_async("a cat", model="xai/grok-imagine-video")) + assert result == done + + def test_video_failure_is_raised_not_returned(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + states = [ + httpx.Response(200, json={"id": "job-1", "status": "queued"}), + httpx.Response(200, json={"id": "job-1", "status": "failed", "error": "nsfw"}), + ] + transport(lambda _r: states.pop(0)) + monkeypatch.setattr(_apikey, "_POLL_INTERVAL_S", 0) + + with pytest.raises(APIError) as excinfo: + asyncio.run(_adapter.video_generation_async("a cat", model="xai/grok-imagine-video")) + assert "nsfw" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# Proxy routes +# --------------------------------------------------------------------------- + + +@pytest.fixture +def proxy_client(monkeypatch): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + return TestClient(proxy.app) + + +class TestProxy: + def test_chat_passthrough_swaps_in_the_key(self, proxy_client, transport): + rec = transport(json_responder(CHAT_BODY)) + response = proxy_client.post( + "/v1/chat/completions", + json={"model": "openai/gpt-5.5", "messages": [{"role": "user", "content": "hi"}]}, + headers={"Authorization": "Bearer a-local-proxy-secret"}, + ) + assert response.status_code == 200 + assert str(rec.last.url) == "https://api.blockrun.ai/v1/chat/completions" + assert rec.last.headers["authorization"] == f"Bearer {KEY}" + + def test_the_clients_own_secret_never_leaves_the_host(self, proxy_client, transport): + """BLOCKRUN_PROXY_TOKEN guards the sidecar locally. Forwarding it to a + remote host would leak the credential it exists to protect.""" + rec = transport(json_responder(CHAT_BODY)) + proxy_client.post( + "/v1/chat/completions", + json={"model": "openai/gpt-5.5", "messages": [{"role": "user", "content": "hi"}]}, + headers={"Authorization": "Bearer a-local-proxy-secret"}, + ) + assert "a-local-proxy-secret" not in str(dict(rec.last.headers)) + + def test_anthropic_messages_passthrough(self, proxy_client, transport): + rec = transport(json_responder({"id": "msg_1", "type": "message", "role": "assistant"})) + response = proxy_client.post( + "/v1/messages", + json={ + "model": "claude-haiku-4.5", + "max_tokens": 8, + "messages": [{"role": "user", "content": "hi"}], + }, + ) + assert response.status_code == 200 + assert str(rec.last.url) == "https://api.blockrun.ai/v1/messages" + + def test_models_comes_from_the_account_catalogue(self, proxy_client, transport): + catalogue = {"object": "list", "data": [{"id": "openai/gpt-5.5", "object": "model"}]} + rec = transport(json_responder(catalogue)) + response = proxy_client.get("/v1/models") + assert response.status_code == 200 + assert response.json() == catalogue + assert str(rec.last.url) == "https://api.blockrun.ai/v1/models" + + def test_native_gemini_is_a_501_that_says_what_to_do(self, proxy_client, transport): + rec = transport(json_responder({})) + response = proxy_client.post( + "/v1beta/models/gemini-2.5-flash:generateContent", + json={"contents": [{"role": "user", "parts": [{"text": "hi"}]}]}, + ) + assert response.status_code == 501 + body = response.json()["error"] + assert body["code"] == "unsupported_on_api_key" + assert "wallet" in body["message"] + assert rec.requests == [], "a 501 must not cost a round trip" + + def test_image_route_reaches_the_published_path(self, proxy_client, transport): + rec = transport(json_responder(IMAGE_BODY)) + response = proxy_client.post( + "/v1/images/edits", + json={ + "prompt": "make it blue", + "image": "https://cdn.example/in.png", + "model": "openai/gpt-image-2", + }, + ) + assert response.status_code == 200 + assert str(rec.last.url) == "https://api.blockrun.ai/v1/images/image2image" + + def test_a_402_reaches_the_client_as_a_402(self, proxy_client, transport): + transport( + json_responder( + {"error": {"message": "Balance exhausted", "code": "quota_exceeded"}}, status=402 + ) + ) + response = proxy_client.post( + "/v1/images/generations", json={"prompt": "a cat", "model": "google/nano-banana"} + ) + assert response.status_code == 402 + assert "user.blockrun.ai" in json.dumps(response.json()) + + +# --------------------------------------------------------------------------- +# Audit log +# --------------------------------------------------------------------------- + + +class TestAuditLog: + """`cost_source` has to distinguish "no charge exists" from "we missed it".""" + + def test_the_provider_marks_the_rail_on_the_response(self, monkeypatch, transport): + """The marker the audit row reads, put there by the non-streaming path. + + Asserted on ``_hidden_params`` rather than through ``litellm.callbacks`` + on purpose: LiteLLM caches its callback list internally, so a + callback-driven assertion here passes alone and fails after any other + test has touched that list. The two halves — marker written, marker + read — are pinned separately instead. + """ + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + transport(json_responder(CHAT_BODY)) + + from blockrun_litellm.provider import BlockRunLLM + + response = BlockRunLLM().completion( + "openai/gpt-5.5", [{"role": "user", "content": "hi"}] + ) + assert response._hidden_params["blockrun_rail"] == "api_key" + # No charge was invented to carry it. + assert "blockrun_cost_usd" not in response._hidden_params + + from blockrun_litellm import logger as _logger + + real = _logger._extract_real_cost(response) + assert real == {"cost_usd": None, "settlement": None, "rail": "api_key"} + assert _logger._cost_fields(real, 0.0009)["cost_source"] == "blockrun_account" + + def test_proxy_rows_are_tagged_too(self, monkeypatch, tmp_path, transport): + """The sidecar has no LiteLLM callback to ride; it logs for itself.""" + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + log = tmp_path / "proxy.jsonl" + monkeypatch.setenv("BLOCKRUN_LITELLM_LOG", str(log)) + transport(json_responder(CHAT_BODY)) + + client = TestClient(proxy.app) + client.post( + "/v1/chat/completions", + json={"model": "openai/gpt-5.5", "messages": [{"role": "user", "content": "hi"}]}, + ) + + rows = [json.loads(line) for line in log.read_text().splitlines() if line.strip()] + assert rows[-1]["cost_source"] == "blockrun_account" + assert rows[-1]["cost_usd"] is None + + def test_wallet_calls_keep_their_own_tags(self, tmp_path): + """The new value must not leak onto the rail that reports real charges.""" + from blockrun_litellm import logger as _logger + + real = {"cost_usd": 0.0021, "settlement": {"tx_hash": "0xabc"}, "rail": None} + assert _logger._cost_fields(real, 0.0009)["cost_source"] == "blockrun_x402" + assert _logger._cost_fields(real, 0.0009)["cost_usd"] == 0.0021 + + none_reported = {"cost_usd": None, "settlement": None, "rail": None} + assert _logger._cost_fields(none_reported, 0.0009) == { + "cost_usd": 0.0009, + "cost_source": "litellm_estimate", + } + + def test_account_row_carries_the_estimate_not_a_settled_number(self): + from blockrun_litellm import logger as _logger + + fields = _logger._cost_fields( + {"cost_usd": None, "settlement": None, "rail": "api_key"}, 0.0009 + ) + assert fields == {"cost_usd": 0.0009, "cost_source": "blockrun_account"} + + +# --------------------------------------------------------------------------- +# Rate limiting +# --------------------------------------------------------------------------- + + +class TestRetryAfter: + """A 429's wait time has to survive the hop, or clients spin instead of wait.""" + + def test_the_header_rides_on_the_exception(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + transport( + json_responder( + {"error": {"message": "Rate limit exceeded", "code": "rate_limit_exceeded"}}, + status=429, + headers={"retry-after": "7"}, + ) + ) + with pytest.raises(APIError) as excinfo: + _adapter.chat_completion_sync("openai/gpt-5.5", [{"role": "user", "content": "hi"}]) + assert excinfo.value.status_code == 429 + assert excinfo.value.retry_after == "7" + + def test_the_proxy_puts_it_back_on_the_wire(self, proxy_client, transport): + transport( + json_responder( + {"error": {"message": "Rate limit exceeded"}}, + status=429, + headers={"retry-after": "7"}, + ) + ) + response = proxy_client.post( + "/v1/chat/completions", + json={"model": "openai/gpt-5.5", "messages": [{"role": "user", "content": "hi"}]}, + ) + assert response.status_code == 429 + assert response.headers["retry-after"] == "7" + + def test_media_routes_surface_it_too(self, proxy_client, transport): + transport( + json_responder( + {"error": {"message": "Rate limit exceeded"}}, + status=429, + headers={"retry-after": "3"}, + ) + ) + response = proxy_client.post( + "/v1/images/generations", json={"prompt": "a cat", "model": "google/nano-banana"} + ) + assert response.status_code == 429 + assert response.headers["retry-after"] == "3" + + def test_a_429_without_the_header_is_still_a_429(self, monkeypatch, transport): + monkeypatch.setenv("BLOCKRUN_API_KEY", KEY) + transport(json_responder({"error": {"message": "slow down"}}, status=429)) + with pytest.raises(APIError) as excinfo: + _adapter.chat_completion_sync("openai/gpt-5.5", [{"role": "user", "content": "hi"}]) + assert excinfo.value.status_code == 429 + assert getattr(excinfo.value, "retry_after", None) is None + + +class TestCacheKeys: + def test_a_wallet_key_is_not_stored_in_a_cache_key(self): + """These strings are dict keys — they land in reprs and tracebacks.""" + secret = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + key = _adapter._client_key("https://blockrun.ai/api", secret) + assert secret not in key + assert key != _adapter._client_key("https://blockrun.ai/api", secret + "0") diff --git a/tests/test_chain_default.py b/tests/test_chain_default.py new file mode 100644 index 0000000..d7b7583 --- /dev/null +++ b/tests/test_chain_default.py @@ -0,0 +1,180 @@ +"""Which chain an unconfigured host uses. + +0.10.0 flipped the wallet-rail default from Base to Solana. The flip is only +half the behaviour: a host that has only ever held a Base wallet keeps using +Base, because upgrading a package must not brick a running deployment whose key +the Solana signer cannot even parse. Both halves are pinned here — the default +and the escape hatch — along with the precedence between them. + +The conftest points the session-file probe at a directory that does not exist, +so "no wallet" means no wallet regardless of what is in the developer's home. +""" + +from __future__ import annotations + +import pytest + +from blockrun_litellm import _adapter + + +@pytest.fixture(autouse=True) +def _clear_cache(): + # _default_wallet_api_url memoizes on the wallet env vars; each case here + # changes them and must not see the previous case's answer. + _adapter._reset_chain_cache_for_tests() + yield + _adapter._reset_chain_cache_for_tests() + + +def test_unconfigured_host_defaults_to_solana(): + assert _adapter.resolve_api_url() == _adapter.SOLANA_API_URL + + +def test_solana_wallet_keeps_the_solana_default(monkeypatch): + monkeypatch.setenv("SOLANA_WALLET_KEY", "base58-looking-key") + assert _adapter.resolve_api_url() == _adapter.SOLANA_API_URL + + +@pytest.mark.parametrize("env", ["BLOCKRUN_WALLET_KEY", "BASE_CHAIN_WALLET_KEY"]) +def test_base_only_host_stays_on_base(monkeypatch, caplog, env): + """The compatibility half of the flip. + + A deployment holding only a Base key would start failing inside the SVM + signer the moment it upgraded. It keeps working — and is told, once, how to + make the choice explicit. + """ + monkeypatch.setenv(env, "0xdeadbeef") + with caplog.at_level("WARNING"): + assert _adapter.resolve_api_url() == _adapter.BASE_API_URL + assert "BLOCKRUN_CHAIN=base" in caplog.text + + +def test_base_wallet_does_not_win_when_a_solana_wallet_exists(monkeypatch): + monkeypatch.setenv("BLOCKRUN_WALLET_KEY", "0xdeadbeef") + monkeypatch.setenv("SOLANA_WALLET_KEY", "base58-looking-key") + assert _adapter.resolve_api_url() == _adapter.SOLANA_API_URL + + +@pytest.mark.parametrize( + "value,expected", + [ + ("solana", _adapter.SOLANA_API_URL), + ("sol", _adapter.SOLANA_API_URL), + ("svm", _adapter.SOLANA_API_URL), + ("base", _adapter.BASE_API_URL), + ("evm", _adapter.BASE_API_URL), + (" BASE ", _adapter.BASE_API_URL), + ], +) +def test_blockrun_chain_selects_the_gateway(monkeypatch, value, expected): + monkeypatch.setenv("BLOCKRUN_CHAIN", value) + assert _adapter.resolve_api_url() == expected + + +def test_explicit_chain_beats_the_wallet_probe(monkeypatch): + """``BLOCKRUN_CHAIN=solana`` on a Base-only host must NOT silently serve Base. + + The auto-detection exists to protect callers who never made a choice. Once + someone has made one, honouring it — and letting the call fail with "no + Solana wallet" — is the only answer that is not a lie about which chain + just moved money. + """ + monkeypatch.setenv("BLOCKRUN_WALLET_KEY", "0xdeadbeef") + monkeypatch.setenv("BLOCKRUN_CHAIN", "solana") + assert _adapter.resolve_api_url() == _adapter.SOLANA_API_URL + + +def test_unknown_chain_warns_and_falls_back_to_detection(monkeypatch, caplog): + monkeypatch.setenv("BLOCKRUN_CHAIN", "ethereum") + with caplog.at_level("WARNING"): + assert _adapter.resolve_api_url() == _adapter.SOLANA_API_URL + assert "not a known chain" in caplog.text + + +# --------------------------------------------------------------------------- +# The chain the CLI recorded, and the chain a key's own shape implies +# --------------------------------------------------------------------------- + + +@pytest.fixture +def chain_files(monkeypatch, tmp_path): + """Point the CLI chain files at a writable directory the test controls.""" + files = (tmp_path / "payment-chain", tmp_path / ".chain") + monkeypatch.setattr(_adapter, "_CHAIN_FILES", files) + return files + + +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize( + "written,expected", + [("base", _adapter.BASE_API_URL), ("solana", _adapter.SOLANA_API_URL), (" BASE\n", _adapter.BASE_API_URL)], +) +def test_a_chain_the_cli_recorded_is_honoured(chain_files, index, written, expected): + """Someone who ran the setup flow has already answered this question. + + Ignoring `~/.blockrun/.chain` was a real bug in the first cut of 0.10.0: + a user who picked Base interactively would have been silently moved to + Solana by the new default, which is precisely the breakage the default's + compatibility branch exists to prevent. + """ + chain_files[index].write_text(written) + assert _adapter.resolve_api_url() == expected + + +def test_the_current_chain_file_beats_the_legacy_one(chain_files): + """Not hypothetical: both files exist on a real machine and disagree there + (`payment-chain` solana, the older `.chain` base). One of them has to win, + and it is the current name.""" + chain_files[0].write_text("solana") + chain_files[1].write_text("base") + assert _adapter.resolve_api_url() == _adapter.SOLANA_API_URL + + +def test_the_legacy_file_still_answers_on_its_own(chain_files): + chain_files[1].write_text("base") + assert _adapter.resolve_api_url() == _adapter.BASE_API_URL + + +def test_an_unreadable_chain_file_does_not_break_resolution(chain_files, caplog): + chain_files[0].write_text("ethereum") + with caplog.at_level("WARNING"): + assert _adapter.resolve_api_url() == _adapter.SOLANA_API_URL + assert "not a known chain" in caplog.text + + +def test_env_beats_the_recorded_chain(chain_files, monkeypatch): + chain_files[0].write_text("base") + monkeypatch.setenv("BLOCKRUN_CHAIN", "solana") + assert _adapter.resolve_api_url() == _adapter.SOLANA_API_URL + + +@pytest.mark.parametrize( + "key,expected", + [ + ("0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", "base"), + ("59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", "base"), + ("5JGiBQ8m1nP1cWQ1kR5nJTaHqZ1YvWc8Vc1sQ3sQ3sQ3sQ3sQ3sQ", "solana"), + ], +) +def test_an_explicit_key_picks_its_own_chain(chain_files, key, expected): + """The key on the call is the wallet that will pay. + + Routing it to the other chain's signer cannot work — a hex key is not + base58 — so the key's own format outranks anything recorded on disk. + """ + chain_files[0].write_text("solana" if expected == "base" else "base") + url = _adapter.resolve_api_url(private_key=key) + assert url == (_adapter.BASE_API_URL if expected == "base" else _adapter.SOLANA_API_URL) + + +def test_api_url_env_beats_chain(monkeypatch): + monkeypatch.setenv("BLOCKRUN_CHAIN", "solana") + monkeypatch.setenv("BLOCKRUN_API_URL", "https://blockrun.ai/api") + assert _adapter.resolve_api_url() == "https://blockrun.ai/api" + assert _adapter._is_solana_url(None) is False + + +def test_explicit_argument_beats_everything(monkeypatch): + monkeypatch.setenv("BLOCKRUN_CHAIN", "solana") + monkeypatch.setenv("BLOCKRUN_API_URL", "https://sol.blockrun.ai/api") + assert _adapter.resolve_api_url("https://staging.example/api") == "https://staging.example/api" diff --git a/tests/test_proxy_gemini_native.py b/tests/test_proxy_gemini_native.py index c1d4dc5..3ec79ef 100644 --- a/tests/test_proxy_gemini_native.py +++ b/tests/test_proxy_gemini_native.py @@ -53,8 +53,9 @@ def post(self, url, content=None, headers=None): @pytest.fixture def client(monkeypatch: pytest.MonkeyPatch) -> TestClient: # _resolve_api_url() reads BLOCKRUN_API_URL first; a developer or CI job with - # it exported (the documented Solana workflow) would otherwise flip the - # absolute-URL assertions below. Pin the Base default for these tests. + # it exported would otherwise flip the absolute-URL assertions below. The + # conftest already clears the credential env and pins the chain probe, so + # dropping this one leaves the documented default (Solana). monkeypatch.delenv("BLOCKRUN_API_URL", raising=False) return TestClient(proxy.app) @@ -105,8 +106,11 @@ def test_generate_content_is_verbatim_and_drops_google_credentials( assert fake.posts == 1 and fake.stream_sends == 0 assert count_semaphore["acquired"] == 1 forwarded = fake.requests[0] + # sol.blockrun.ai, not blockrun.ai: nothing here configures a chain, and the + # unconfigured default is Solana as of 0.10.0. The native Gemini surface is + # published on both gateways, so the flip changes the host and nothing else. assert str(forwarded.url) == ( - "https://blockrun.ai/api/v1beta/models/gemini-2.5-flash:generateContent" + "https://sol.blockrun.ai/api/v1beta/models/gemini-2.5-flash:generateContent" ) assert forwarded.content == raw assert "x-goog-api-key" not in forwarded.headers @@ -140,7 +144,7 @@ def test_stream_generate_content_uses_url_method_and_preserves_sse( assert fake.stream_sends == 1 and fake.posts == 0 assert count_semaphore["acquired"] == 1 assert str(fake.requests[0].url) == ( - "https://blockrun.ai/api/v1beta/models/" + "https://sol.blockrun.ai/api/v1beta/models/" "gemini-2.5-flash:streamGenerateContent" ) diff --git a/tests/test_proxy_media.py b/tests/test_proxy_media.py index f65e2c1..0435ecd 100644 --- a/tests/test_proxy_media.py +++ b/tests/test_proxy_media.py @@ -266,7 +266,7 @@ def test_image_quality_on_base_is_served_dropped_and_warned(self, client, monkey a drop-in-compatible route — but surface the drop in a header so it isn't the silent no-op 0.6.1 had. """ - monkeypatch.delenv("BLOCKRUN_API_URL", raising=False) # Base + monkeypatch.setenv("BLOCKRUN_CHAIN", "base") mock = _mock_adapter(monkeypatch, "image_generation_async", return_value=dict(OK_RESULT)) response = client.post( "/v1/images/generations", @@ -277,7 +277,7 @@ def test_image_quality_on_base_is_served_dropped_and_warned(self, client, monkey assert "Solana only" in response.headers.get("x-blockrun-warning", "") def test_image_edit_quality_on_base_is_served_dropped_and_warned(self, client, monkeypatch): - monkeypatch.delenv("BLOCKRUN_API_URL", raising=False) + monkeypatch.setenv("BLOCKRUN_CHAIN", "base") mock = _mock_adapter(monkeypatch, "image_edit_async", return_value=dict(OK_RESULT)) response = client.post( "/v1/images/edits", diff --git a/tests/test_settlement_status.py b/tests/test_settlement_status.py index f3779a6..44d6c30 100644 --- a/tests/test_settlement_status.py +++ b/tests/test_settlement_status.py @@ -73,7 +73,9 @@ def _solana(monkeypatch): def _base(monkeypatch): + # Base has to be asked for since 0.10.0 — an unset gateway URL means Solana. monkeypatch.delenv("BLOCKRUN_API_URL", raising=False) + monkeypatch.setenv("BLOCKRUN_CHAIN", "base") def _last_row(path) -> Dict[str, Any]: