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
17 changes: 12 additions & 5 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",
"uberllm/deepseek/deepseek-v4.1-flash",
"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': 'uberllm/deepseek/deepseek-v4.1-flash', 'backend': 'uberllm', 'model': 'deepseek/deepseek-v4.1-flash'}
{'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 |
| `uberllm` | `UBERLLM_API_KEY` | one key, many vendors, per-call cost in the response |
| `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`, `uberllm` 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, uberllm, 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, uberllm, ollama, mock
openrouter key: <unset>
openai key: <unset>
novita key: <unset>
uberllm 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
uberllm/deepseek/deepseek-v4.1-flash one key, many vendors, cost per call
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 @@ -172,6 +177,8 @@ 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>
uberllm unusable no API key (set UBERLLM_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
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,425 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 @@ -470,6 +470,7 @@ def _cmd_models(args: argparse.Namespace) -> int:
openai_api_key,
openrouter_api_key,
redact,
uberllm_api_key,
)
from grapharc.gateway.registry import BACKENDS

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",
"uberllm/deepseek/deepseek-v4.1-flash": "one key, many vendors, cost per call",
"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()),
"uberllm_key": redact(uberllm_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("uberllm key", redact(uberllm_api_key())),
style.kv("ollama url", ollama_base_url(), tint=style.accent),
"",
style.heading("examples:"),
Expand Down
21 changes: 21 additions & 0 deletions grapharc/cli/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,26 @@ def _probe_novita() -> dict[str, Any]:
}


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

key = uberllm_api_key()
has_dependency = importlib.util.find_spec("langchain_openai") is not None
missing = []
if not key:
missing.append("no API key (set UBERLLM_API_KEY, or add one to .env)")
if not has_dependency:
missing.append("langchain-openai not installed (uv sync --extra uberllm)")
return {
"backend": "uberllm",
"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.uberllm.dev",
}


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

Expand Down Expand Up @@ -173,6 +193,7 @@ def probe_backends(*, claude_path: str = "claude") -> list[dict[str, Any]]:
"openrouter": _probe_openrouter,
"openai": _probe_openai,
"novita": _probe_novita,
"uberllm": _probe_uberllm,
"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("uberllm/deepseek/deepseek-v4.1-flash") # UBERLLM_API_KEY
get_model("ollama/llama3.1") # local server, no key
get_model("mock/x", responses=[...]) # deterministic tests

Expand All @@ -17,8 +18,9 @@
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`, `uberllm`,
`ollama`) are imported lazily — they need `langchain-openai`, which is an
optional extra.
"""

from grapharc.gateway.claude_cli import ClaudeCodeCLIChatModel
Expand All @@ -30,6 +32,7 @@
openai_base_url,
openrouter_api_key,
redact,
uberllm_api_key,
)
from grapharc.gateway.errors import (
CostCeilingExceeded,
Expand Down Expand Up @@ -76,6 +79,7 @@
"openrouter_api_key",
"redact",
"split_spec",
"uberllm_api_key",
"vendor",
]

Expand All @@ -90,6 +94,8 @@
"OpenAIError": "openai",
"NovitaChatModel": "novita",
"NovitaError": "novita",
"UberLLMChatModel": "uberllm",
"UberLLMError": "uberllm",
"OllamaChatModel": "ollama",
"OllamaError": "ollama",
}
Expand Down
15 changes: 14 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,8 @@
- **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.
- **UberLLM** — `UBERLLM_API_KEY`. The endpoint is fixed too
(`grapharc.gateway.uberllm.UBERLLM_BASE_URL`).
- **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 +68,13 @@
"novita_api_key",
)

UBERLLM_KEYS = (
"UBERLLM_API_KEY",
"UBERLLM_KEY",
"uberllm-api-key",
"uberllm_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 +156,10 @@ def novita_api_key(*, env_file: Path | None = None) -> str | None:
return get_secret(NOVITA_KEYS, env_file=env_file)


def uberllm_api_key(*, env_file: Path | None = None) -> str | None:
return get_secret(UBERLLM_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
15 changes: 14 additions & 1 deletion grapharc/gateway/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
openrouter/openai/gpt-4o:floor -> OpenRouter, cheapest provider
openai/gpt-4o-mini -> OpenAI directly (OPENAI_API_KEY)
novita/moonshotai/kimi-k3 -> Novita (NOVITA_API_KEY)
uberllm/deepseek/deepseek-v4.1-flash -> UberLLM (UBERLLM_API_KEY)
ollama/llama3.1 -> a local Ollama server, no key
mock/anything -> scripted test double

Expand All @@ -29,7 +30,7 @@ class UnknownBackendError(Exception):
"""The spec named a backend that is not registered."""


BACKENDS = ("claude-cli", "openrouter", "openai", "novita", "ollama", "mock")
BACKENDS = ("claude-cli", "openrouter", "openai", "novita", "uberllm", "ollama", "mock")

# Authors that appear in OpenRouter model ids. A spec starting with one of
# these is a model name, not a mistyped backend — `anthropic/claude-haiku-4.5`
Expand Down Expand Up @@ -73,6 +74,7 @@ class UnknownBackendError(Exception):
"openrouter": "openrouter/anthropic/claude-sonnet-4.5",
"openai": "openai/gpt-4o-mini",
"novita": "novita/moonshotai/kimi-k3",
"uberllm": "uberllm/deepseek/deepseek-v4.1-flash",
"ollama": "ollama/llama3.1",
}

Expand Down Expand Up @@ -173,6 +175,17 @@ def get_model(spec: str, **kwargs: Any) -> BaseChatModel:

return NovitaChatModel(model, **kwargs)

if backend == "uberllm":
try:
from grapharc.gateway.uberllm import UberLLMChatModel
except ImportError as exc: # pragma: no cover - depends on install extras
raise UnknownBackendError(
"The UberLLM backend needs langchain-openai. "
"Install it with: uv sync --extra uberllm"
) from exc

return UberLLMChatModel(model, **kwargs)

if backend == "ollama":
try:
from grapharc.gateway.ollama import OllamaChatModel
Expand Down
62 changes: 62 additions & 0 deletions grapharc/gateway/uberllm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""UberLLM backend — one key for models from many vendors, priced per call.

`uberllm/deepseek/deepseek-v4.1-flash` reaches UberLLM's own OpenAI-compatible
endpoint (`https://api.uberllm.dev/v1`), not api.openai.com, so this builds on
`OpenAICompatChatModel` the same way `openrouter.py` and `novita.py` do rather
than on `openai.py`: the endpoint is fixed, not an override of OpenAI's own.

Model ids on UberLLM are themselves `vendor/model` — `deepseek/deepseek-v4.1-flash`,
`anthropic/claude-sonnet-4.6` — the same shape OpenRouter uses, so `vendor()` in
`registry.py` already reads the right author off an UberLLM spec with no
backend-specific handling: `BACKEND_VENDOR` stays absent for `uberllm`.

**UberLLM prices its own calls.** The chat-completions usage block carries
`cost_usd`, what the call was billed, so like OpenRouter a `cost_ceiling_usd`
on this backend is an enforcement rather than a counter — except on streamed
turns, where the usage block arrives in the final SSE chunk and does not survive
chunk conversion.
"""

from __future__ import annotations

from typing import Any

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

UBERLLM_BASE_URL = "https://api.uberllm.dev/v1"


class UberLLMError(Exception):
"""The UberLLM backend could not be constructed or used."""


class UberLLMChatModel(OpenAICompatChatModel):
"""A LangChain chat model over UberLLM's OpenAI-compatible endpoint."""

def __init__(self, model: str, /, **kwargs: Any) -> None:
api_key = kwargs.pop("api_key", None) or uberllm_api_key()
if not api_key:
raise UberLLMError(
"No UberLLM API key found. Set UBERLLM_API_KEY in the environment, "
"or add one of UBERLLM_API_KEY / uberllm-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 UBERLLM_BASE_URL,
**kwargs,
)

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

def _provider_cost(self, usage: dict[str, Any]) -> float | None:
"""UberLLM reports what the call was billed in its usage block."""
return usage.get("cost_usd")


__all__ = ["UBERLLM_BASE_URL", "UberLLMChatModel", "UberLLMError"]
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ openai = [
novita = [
"langchain-openai>=0.2",
]
# Imported by grapharc/gateway/uberllm.py. UberLLM speaks the OpenAI wire format
# against its own endpoint, so this needs the same client and no UberLLM-specific
# package.
uberllm = [
"langchain-openai>=0.2",
]
# Imported by grapharc/gateway/ollama.py. Ollama speaks the OpenAI wire format,
# so the local backend needs the same client and no ollama-specific package.
ollama = [
Expand Down Expand Up @@ -121,7 +127,7 @@ slack = [
]
# Everything above. Self-referential so it cannot drift out of sync.
all = [
"grapharc[api,ladybug,mcp,novita,ollama,openai,openrouter,otel,server,slack]",
"grapharc[api,ladybug,mcp,novita,ollama,openai,openrouter,otel,server,slack,uberllm]",
]

[dependency-groups]
Expand Down
4 changes: 3 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ def test_models_check_exits_one_when_nothing_is_configured(monkeypatch, capsys):
"OPENROUTER_API_KEY", "OPENROUTER_KEY", "open-router-api-key",
"OPENAI_API_KEY", "OPENAI_KEY", "openai-api-key",
"NOVITA_API_KEY", "NOVITA_KEY", "novita-api-key",
"UBERLLM_API_KEY", "UBERLLM_KEY", "uberllm-api-key",
"OLLAMA_HOST", "OLLAMA_BASE_URL",
):
monkeypatch.delenv(name, raising=False)
Expand All @@ -461,10 +462,11 @@ def test_models_check_exits_one_when_nothing_is_configured(monkeypatch, capsys):
"openrouter": False,
"openai": False,
"novita": False,
"uberllm": False,
"ollama": False,
"mock": True,
}
for backend in ("openrouter", "openai", "novita"):
for backend in ("openrouter", "openai", "novita", "uberllm"):
assert next(b for b in payload["backends"] if b["backend"] == backend)[
"credential"
] == "<unset>"
Expand Down
Loading