Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 23 additions & 16 deletions docs/cookbook/02-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ for spec in (
"openrouter/anthropic/claude-haiku-4.5",
"openai/gpt-4o-mini",
"novita/moonshotai/kimi-k3",
"cheaperinference/gpt-5.4-mini",
"ollama/llama3.1",
"mock/anything",
):
Expand All @@ -40,19 +41,21 @@ print(model.invoke("say hi").content)
{'spec': 'openrouter/anthropic/claude-haiku-4.5', 'backend': 'openrouter', 'model': 'anthropic/claude-haiku-4.5'}
{'spec': 'openai/gpt-4o-mini', 'backend': 'openai', 'model': 'gpt-4o-mini'}
{'spec': 'novita/moonshotai/kimi-k3', 'backend': 'novita', 'model': 'moonshotai/kimi-k3'}
{'spec': 'cheaperinference/gpt-5.4-mini', 'backend': 'cheaperinference', 'model': 'gpt-5.4-mini'}
{'spec': 'ollama/llama3.1', 'backend': 'ollama', 'model': 'llama3.1'}
{'spec': 'mock/anything', 'backend': 'mock', 'model': 'anything'}
hello from a scripted model
```

There are six backends:
There are seven backends:

| backend | credential | what it is for |
| --- | --- | --- |
| `claude-cli` | a Claude subscription, no API key | text completion on quota you already pay for |
| `openrouter` | `OPENROUTER_API_KEY` | one key, most vendors, per-call cost in the response |
| `openai` | `OPENAI_API_KEY` | the OpenAI API directly, or any endpoint via `OPENAI_BASE_URL` |
| `novita` | `NOVITA_API_KEY` | Novita's own endpoint, token counts but no per-call cost |
| `cheaperinference` | `CHEAPER_INFERENCE_API_KEY` | Cheaper Inference's own endpoint, bare model ids, no per-call cost read |
| `ollama` | none — a local server | models on your own machine, free and offline |
| `mock` | none | a scripted test double |

Expand All @@ -66,8 +69,8 @@ needs it. Asking for `claude-cli` therefore does not require `langchain-openai`,
for `openrouter` does not require the Claude CLI to be installed. A missing optional
dependency fails for the backend that wanted it and nothing else.

`openrouter`, `openai`, `novita` and `ollama` all speak the OpenAI wire format and share one
base class, so they behave identically on everything except money and routing: same
`openrouter`, `openai`, `novita`, `cheaperinference` and `ollama` all speak the OpenAI wire
format and share one base class, so they behave identically on everything except money and routing: same
`bind_tools`, same `with_structured_output`, same streaming and async, same retry policy,
same usage envelope.

Expand Down Expand Up @@ -96,7 +99,7 @@ except UnknownBackendError as exc:
('openrouter', 'openai/gpt-4o-mini:floor')
('claude-cli', 'anthropic/claude-haiku-4.5')
('openai', 'gpt-4o-mini')
UnknownBackendError: unknown backend 'opnerouter' in spec 'opnerouter/openai/gpt-4o-mini'; expected one of: claude-cli, openrouter, openai, novita, ollama, mock — or a bare model name for the claude-cli default
UnknownBackendError: unknown backend 'opnerouter' in spec 'opnerouter/openai/gpt-4o-mini'; expected one of: claude-cli, openrouter, openai, novita, cheaperinference, ollama, mock — or a bare model name for the claude-cli default
```

Only the first segment is a backend, because OpenRouter model ids are themselves
Expand Down Expand Up @@ -125,10 +128,11 @@ model through the broker is still `openrouter/openai/gpt-4o-mini`.
<!-- verified: cli -->
```console
$ grapharc models
backends: claude-cli, openrouter, openai, novita, ollama, mock
backends: claude-cli, openrouter, openai, novita, cheaperinference, ollama, mock
openrouter key: <unset>
openai key: <unset>
novita key: <unset>
cheaper inference key: <unset>
ollama url: http://localhost:11434/v1

examples:
Expand All @@ -137,6 +141,7 @@ examples:
openrouter/openai/gpt-4o-mini:floor cheapest provider for that model
openai/gpt-4o-mini the OpenAI API directly, your key
novita/moonshotai/kimi-k3 Novita's own endpoint, your key
cheaperinference/gpt-5.4-mini Cheaper Inference's own endpoint, your key
ollama/llama3.1 a local server, no key and no bill

grapharc models --check probes which of these this machine can use
Expand Down Expand Up @@ -164,17 +169,19 @@ keys at all:
<!-- verified: cli varies -->
```console
$ grapharc models --check
claude-cli usable 'claude' on PATH at ~/.local/bin/claude
credential: claude subscription login (no API key)
openrouter unusable no API key (set OPENROUTER_API_KEY, or add one to .env)
credential: <unset>
openai unusable no API key (set OPENAI_API_KEY, or add one to .env)
credential: <unset>
novita unusable no API key (set NOVITA_API_KEY, or add one to .env)
credential: <unset>
ollama usable local server at http://localhost:11434/v1
credential: none needed (local server)
mock usable scripted test double; never reaches a provider
claude-cli usable 'claude' on PATH at ~/.local/bin/claude
credential: claude subscription login (no API key)
openrouter unusable no API key (set OPENROUTER_API_KEY, or add one to .env)
credential: <unset>
openai unusable no API key (set OPENAI_API_KEY, or add one to .env)
credential: <unset>
novita unusable no API key (set NOVITA_API_KEY, or add one to .env)
credential: <unset>
cheaperinference unusable no API key (set CHEAPER_INFERENCE_API_KEY, or add one to .env)
credential: <unset>
ollama usable local server at http://localhost:11434/v1
credential: none needed (local server)
mock usable scripted test double; never reaches a provider

local probe only — no provider was contacted, so a configured key
is not a validated one.
Expand Down
22 changes: 13 additions & 9 deletions docs/cookbook/06-serving-and-ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -1903,15 +1903,19 @@ model: anthropic/claude-haiku-4.5
<!-- verified: cli varies -->
```console
$ grapharc models --check
claude-cli usable 'claude' on PATH at /home/you/.local/bin/claude
credential: claude subscription login (no API key)
openrouter unusable no API key (set OPENROUTER_API_KEY, or add one to .env)
credential: <unset>
openai unusable no API key (set OPENAI_API_KEY, or add one to .env)
credential: <unset>
ollama usable local server at http://localhost:11434/v1
credential: none needed (local server)
mock usable scripted test double; never reaches a provider
claude-cli usable 'claude' on PATH at /home/you/.local/bin/claude
credential: claude subscription login (no API key)
openrouter unusable no API key (set OPENROUTER_API_KEY, or add one to .env)
credential: <unset>
openai unusable no API key (set OPENAI_API_KEY, or add one to .env)
credential: <unset>
novita unusable no API key (set NOVITA_API_KEY, or add one to .env)
credential: <unset>
cheaperinference unusable no API key (set CHEAPER_INFERENCE_API_KEY, or add one to .env)
credential: <unset>
ollama usable local server at http://localhost:11434/v1
credential: none needed (local server)
mock usable scripted test double; never reaches a provider

local probe only — no provider was contacted, so a configured key
is not a validated one.
Expand Down
2 changes: 1 addition & 1 deletion docs/deep-dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ A stable system is not one that claims to have no edges — it is one whose edge
- **`.env` and `grapharc.toml` follow the same discovery rule: the working directory, and nowhere else.** Neither searches parent directories — a run must not be governed by a file you did not know about, and must not be *billed* to one either. **This is a behaviour change:** the credential loader used to walk up to `/`, so a `.env` in an ancestor directory (a `$HOME` one on a shared box, a client project one above a demo checkout) was picked up silently. If you relied on that, move the file into the directory you run from, `export` the variable, or pass `env_file=` to name it explicitly. A real environment variable still beats any file.
- **`grapharc run` has no budget unless you give it one.** Set any of `--max-tokens`, `--max-iterations`, `--max-seconds`, or `--max-concurrency`; without them each dimension is unlimited and the gate admits a topology of any worst-case cost.

**Verified this pass:** `pytest` → green, 2,408 selected and 13 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.8` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.
**Verified this pass:** `pytest` → green, 2,424 selected and 14 deselected (the live ones); `ruff check .` clean; all eight `grapharc demo` stages green, plus the `trace` / `metrics` / `viz` / `replay` tour against a freshly recorded demo trace; the wheel builds and imports all submodules in a clean virtualenv with `[all]`, and `0.1.8` on PyPI is that wheel. The counts are a snapshot, not a property of the project — `pytest` re-derives them in one command, which is the only reason they are quoted, and `tests/test_deep_dive.py` fails this line rather than letting it drift.

[ROADMAP.md](../ROADMAP.md) tracks what is built and what is not, item by item.

Expand Down
4 changes: 4 additions & 0 deletions grapharc/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ def _cmd_approve(args: argparse.Namespace) -> int:

def _cmd_models(args: argparse.Namespace) -> int:
from grapharc.gateway import (
cheaper_inference_api_key,
describe,
novita_api_key,
ollama_base_url,
Expand Down Expand Up @@ -515,6 +516,7 @@ def _cmd_models(args: argparse.Namespace) -> int:
"openrouter/openai/gpt-4o-mini:floor": "cheapest provider for that model",
"openai/gpt-4o-mini": "the OpenAI API directly, your key",
"novita/moonshotai/kimi-k3": "Novita's own endpoint, your key",
"cheaperinference/gpt-5.4-mini": "Cheaper Inference's own endpoint, your key",
"ollama/llama3.1": "a local server, no key and no bill",
}
payload = {
Expand All @@ -524,6 +526,7 @@ def _cmd_models(args: argparse.Namespace) -> int:
"openrouter_key": redact(openrouter_api_key()),
"openai_key": redact(openai_api_key()),
"novita_key": redact(novita_api_key()),
"cheaper_inference_key": redact(cheaper_inference_api_key()),
# An address, not a secret: it is printed whole, and it is where a
# request would go rather than proof that anything is listening.
"ollama_base_url": ollama_base_url(),
Expand All @@ -536,6 +539,7 @@ def _cmd_models(args: argparse.Namespace) -> int:
style.kv("openrouter key", redact(openrouter_api_key())),
style.kv("openai key", redact(openai_api_key())),
style.kv("novita key", redact(novita_api_key())),
style.kv("cheaper inference key", redact(cheaper_inference_api_key())),
style.kv("ollama url", ollama_base_url(), tint=style.accent),
"",
style.heading("examples:"),
Expand Down
28 changes: 25 additions & 3 deletions grapharc/cli/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@
from grapharc.cli import style

#: The three columns every row prints, in characters. Constants, not measurements
#: of the terminal: `tests/test_cli.py` asserts on `"bedrock unprobed"`, and
#: the page in `docs/cookbook/02-models.md` shows this table verbatim.
BACKEND_COLUMN = 12
#: of the terminal: `tests/test_cli.py` asserts on `"bedrock unprobed"`,
#: and the page in `docs/cookbook/02-models.md` shows this table verbatim. The
#: backend column fits the longest name, `cheaperinference`, plus one space.
BACKEND_COLUMN = 17
MARK_COLUMN = 9

# A backend that is a test double rather than a provider. `--check` exits
Expand Down Expand Up @@ -115,6 +116,26 @@ def _probe_novita() -> dict[str, Any]:
}


def _probe_cheaperinference() -> dict[str, Any]:
from grapharc.gateway import cheaper_inference_api_key, redact

key = cheaper_inference_api_key()
has_dependency = importlib.util.find_spec("langchain_openai") is not None
missing = []
if not key:
missing.append("no API key (set CHEAPER_INFERENCE_API_KEY, or add one to .env)")
if not has_dependency:
missing.append("langchain-openai not installed (uv sync --extra cheaperinference)")
return {
"backend": "cheaperinference",
"kind": KIND_PROVIDER,
"usable": bool(key) and has_dependency,
"credential": redact(key),
"detail": "; ".join(missing) or "api key configured and langchain-openai installed",
"checked": "credential presence only; no request was sent to api.cheaperinference.com",
}


def _probe_ollama() -> dict[str, Any]:
from grapharc.gateway import ollama_base_url

Expand Down Expand Up @@ -173,6 +194,7 @@ def probe_backends(*, claude_path: str = "claude") -> list[dict[str, Any]]:
"openrouter": _probe_openrouter,
"openai": _probe_openai,
"novita": _probe_novita,
"cheaperinference": _probe_cheaperinference,
"ollama": _probe_ollama,
"mock": _probe_mock,
}
Expand Down
10 changes: 8 additions & 2 deletions grapharc/gateway/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
get_model("openrouter/anthropic/claude-sonnet-4.5") # many providers, one key
get_model("openai/gpt-4o-mini") # OPENAI_API_KEY
get_model("novita/moonshotai/kimi-k3") # NOVITA_API_KEY
get_model("cheaperinference/gpt-5.4-mini") # CHEAPER_INFERENCE_API_KEY
get_model("ollama/llama3.1") # local server, no key
get_model("mock/x", responses=[...]) # deterministic tests

Expand All @@ -17,12 +18,14 @@
get_model(spec, cost_ceiling_usd=0.25) # raises when passed
get_model(spec, spend=shared_meter) # one ceiling, many models

The four OpenAI-wire backends (`openrouter`, `openai`, `novita`, `ollama`) are
imported lazily — they need `langchain-openai`, which is an optional extra.
The five OpenAI-wire backends (`openrouter`, `openai`, `novita`,
`cheaperinference`, `ollama`) are imported lazily — they need
`langchain-openai`, which is an optional extra.
"""

from grapharc.gateway.claude_cli import ClaudeCodeCLIChatModel
from grapharc.gateway.config import (
cheaper_inference_api_key,
novita_api_key,
ollama_api_key,
ollama_base_url,
Expand Down Expand Up @@ -64,6 +67,7 @@
"TransientGatewayError",
"UnknownBackendError",
"call_with_retry",
"cheaper_inference_api_key",
"describe",
"different_providers",
"get_model",
Expand All @@ -90,6 +94,8 @@
"OpenAIError": "openai",
"NovitaChatModel": "novita",
"NovitaError": "novita",
"CheaperInferenceChatModel": "cheaperinference",
"CheaperInferenceError": "cheaperinference",
"OllamaChatModel": "ollama",
"OllamaError": "ollama",
}
Expand Down
68 changes: 68 additions & 0 deletions grapharc/gateway/cheaperinference.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Cheaper Inference backend — an OpenAI-compatible gateway, one key.

`cheaperinference/gpt-5.4-mini` reaches Cheaper Inference's own
OpenAI-compatible endpoint (`https://api.cheaperinference.com/v1`), not
api.openai.com, so this builds on `OpenAICompatChatModel` the same way
`novita.py` and `openrouter.py` do rather than on `openai.py`: the endpoint is
fixed, not an override of OpenAI's own.

**Model ids here are bare** — `gpt-5.4-mini`, `claude-sonnet-5`,
`deepseek-v4-flash` — with no `author/` prefix, unlike OpenRouter and Novita.
So `vendor()` in `registry.py` has no author to read, and every spec on this
backend reads as the vendor `cheaperinference`. `BACKEND_VENDOR` deliberately
does not map it to one lab, because the gateway serves several. The
consequence for `different_providers` is the re-seller limit its docstring
already names: two `cheaperinference/…` specs always read as correlated, even
across labs, and `cheaperinference/gpt-5.4-mini` against `openai/gpt-5.4-mini`
reads as independent although both name the same OpenAI model.

**This backend reads no per-call cost.** `_provider_cost` is the base class's
`None`, so it is the OpenAI one in that respect: `cost_ceiling_usd` counts
calls in `SpendMeter.unpriced_calls` unless a caller supplies
`price_per_million=`.
"""

from __future__ import annotations

from typing import Any

from grapharc.gateway.config import cheaper_inference_api_key
from grapharc.gateway.openai_compat import OpenAICompatChatModel

CHEAPER_INFERENCE_BASE_URL = "https://api.cheaperinference.com/v1"


class CheaperInferenceError(Exception):
"""The Cheaper Inference backend could not be constructed or used."""


class CheaperInferenceChatModel(OpenAICompatChatModel):
"""A LangChain chat model over Cheaper Inference's OpenAI-compatible endpoint."""

def __init__(self, model: str, /, **kwargs: Any) -> None:
api_key = kwargs.pop("api_key", None) or cheaper_inference_api_key()
if not api_key:
raise CheaperInferenceError(
"No Cheaper Inference API key found. Set CHEAPER_INFERENCE_API_KEY "
"in the environment, or add one of CHEAPER_INFERENCE_API_KEY / "
"cheaper-inference-api-key to a .env file."
)
# One retry layer, not two — same reasoning as the OpenRouter backend.
kwargs.setdefault("max_retries", 0)
super().__init__(
model=model,
api_key=api_key,
base_url=kwargs.pop("base_url", None) or CHEAPER_INFERENCE_BASE_URL,
**kwargs,
)

@property
def _llm_type(self) -> str:
return "grapharc-cheaperinference"


__all__ = [
"CHEAPER_INFERENCE_BASE_URL",
"CheaperInferenceChatModel",
"CheaperInferenceError",
]
16 changes: 15 additions & 1 deletion grapharc/gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
Secrets are returned, never logged. Anything that renders a config for humans
goes through `redact`.

Four key-holding backends, plus one that usually holds none:
Five key-holding backends, plus one that usually holds none:

- **OpenRouter** — `OPENROUTER_API_KEY`.
- **OpenAI** — `OPENAI_API_KEY`, optionally with `OPENAI_BASE_URL` for an
Expand All @@ -31,6 +31,9 @@
- **Novita** — `NOVITA_API_KEY`. The endpoint is fixed
(`grapharc.gateway.novita.NOVITA_BASE_URL`), so unlike OpenAI there is no
base-url override to resolve here.
- **Cheaper Inference** — `CHEAPER_INFERENCE_API_KEY`. The endpoint is fixed
(`grapharc.gateway.cheaperinference.CHEAPER_INFERENCE_BASE_URL`), the same
as Novita's.
- **Ollama** — no credential by default: it is a server on your own machine.
What it needs is an address, so `ollama_base_url()` always returns one
(`OLLAMA_HOST` / `OLLAMA_BASE_URL`, else localhost). `OLLAMA_API_KEY` exists
Expand Down Expand Up @@ -66,6 +69,13 @@
"novita_api_key",
)

CHEAPER_INFERENCE_KEYS = (
"CHEAPER_INFERENCE_API_KEY",
"CHEAPER_INFERENCE_KEY",
"cheaper-inference-api-key",
"cheaper_inference_api_key",
)

# `OPENAI_API_BASE` is the older spelling and is still what a lot of tooling
# sets; both are accepted, the current one first.
OPENAI_BASE_URL_KEYS = (
Expand Down Expand Up @@ -147,6 +157,10 @@ def novita_api_key(*, env_file: Path | None = None) -> str | None:
return get_secret(NOVITA_KEYS, env_file=env_file)


def cheaper_inference_api_key(*, env_file: Path | None = None) -> str | None:
return get_secret(CHEAPER_INFERENCE_KEYS, env_file=env_file)


def openai_base_url(*, env_file: Path | None = None) -> str | None:
"""An endpoint override, or None for api.openai.com.

Expand Down
Loading