From 7f98eafb7acbb5c489eb2979ab8c5c9cce6b811a Mon Sep 17 00:00:00 2001 From: jax-novita Date: Wed, 5 Aug 2026 13:46:09 +0800 Subject: [PATCH 1/3] Add Novita as a model gateway backend Novita hosts open-weight models behind its own OpenAI-compatible endpoint (api.novita.ai/openai), reached with a single NOVITA_API_KEY. Model ids are already author/slug (deepseek/deepseek-r1), the same shape OpenRouter uses, so no special-casing is needed in vendor(). Follows the existing openrouter/openai/ollama backends: a thin subclass of OpenAICompatChatModel, credentials resolved through the same env/.env lookup in config.py, and a new branch in the registry's get_model(). Like OpenAI, Novita reports no per-call cost, so calls are counted as unpriced unless a rate card is supplied. --- docs/cookbook/02-models.md | 17 ++- grapharc/cli/main.py | 4 + grapharc/cli/probe.py | 21 +++ grapharc/gateway/__init__.py | 9 +- grapharc/gateway/config.py | 16 ++- grapharc/gateway/novita.py | 59 ++++++++ grapharc/gateway/registry.py | 15 +- pyproject.toml | 8 +- tests/test_cli.py | 4 +- tests/test_cookbook_models.py | 22 ++- tests/test_gateway_novita.py | 209 ++++++++++++++++++++++++++++ tests/test_gateway_openai_ollama.py | 2 +- tests/test_gateway_openrouter.py | 4 +- uv.lock | 8 +- 14 files changed, 379 insertions(+), 19 deletions(-) create mode 100644 grapharc/gateway/novita.py create mode 100644 tests/test_gateway_novita.py diff --git a/docs/cookbook/02-models.md b/docs/cookbook/02-models.md index 1c90f75..10e87fa 100644 --- a/docs/cookbook/02-models.md +++ b/docs/cookbook/02-models.md @@ -24,6 +24,7 @@ for spec in ( "claude-cli/claude-sonnet-5", "openrouter/anthropic/claude-haiku-4.5", "openai/gpt-4o-mini", + "novita/deepseek/deepseek-r1", "ollama/llama3.1", "mock/anything", ): @@ -38,18 +39,20 @@ print(model.invoke("say hi").content) {'spec': 'claude-cli/claude-sonnet-5', 'backend': 'claude-cli', 'model': 'claude-sonnet-5'} {'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/deepseek/deepseek-r1', 'backend': 'novita', 'model': 'deepseek/deepseek-r1'} {'spec': 'ollama/llama3.1', 'backend': 'ollama', 'model': 'llama3.1'} {'spec': 'mock/anything', 'backend': 'mock', 'model': 'anything'} hello from a scripted model ``` -There are five backends: +There are six 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 | | `ollama` | none — a local server | models on your own machine, free and offline | | `mock` | none | a scripted test double | @@ -63,8 +66,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` 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` 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. @@ -93,7 +96,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, 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, 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 @@ -122,9 +125,10 @@ model through the broker is still `openrouter/openai/gpt-4o-mini`. ```console $ grapharc models -backends: claude-cli, openrouter, openai, ollama, mock +backends: claude-cli, openrouter, openai, novita, ollama, mock openrouter key: openai key: +novita key: ollama url: http://localhost:11434/v1 examples: @@ -132,6 +136,7 @@ examples: openrouter/anthropic/claude-haiku-4.5 many providers, one key openrouter/openai/gpt-4o-mini:floor cheapest provider for that model openai/gpt-4o-mini the OpenAI API directly, your key + novita/deepseek/deepseek-r1 Novita'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 @@ -165,6 +170,8 @@ openrouter unusable no API key (set OPENROUTER_API_KEY, or add one to .env) credential: openai unusable no API key (set OPENAI_API_KEY, or add one to .env) credential: +novita unusable no API key (set NOVITA_API_KEY, or add one to .env) + credential: ollama usable local server at http://localhost:11434/v1 credential: none needed (local server) mock usable scripted test double; never reaches a provider diff --git a/grapharc/cli/main.py b/grapharc/cli/main.py index 3aed159..532dc60 100644 --- a/grapharc/cli/main.py +++ b/grapharc/cli/main.py @@ -449,6 +449,7 @@ def _cmd_approve(args: argparse.Namespace) -> int: def _cmd_models(args: argparse.Namespace) -> int: from grapharc.gateway import ( describe, + novita_api_key, ollama_base_url, openai_api_key, openrouter_api_key, @@ -497,6 +498,7 @@ def _cmd_models(args: argparse.Namespace) -> int: "openrouter/anthropic/claude-haiku-4.5": "many providers, one key", "openrouter/openai/gpt-4o-mini:floor": "cheapest provider for that model", "openai/gpt-4o-mini": "the OpenAI API directly, your key", + "novita/deepseek/deepseek-r1": "Novita's own endpoint, your key", "ollama/llama3.1": "a local server, no key and no bill", } payload = { @@ -505,6 +507,7 @@ def _cmd_models(args: argparse.Namespace) -> int: "backends": list(BACKENDS), "openrouter_key": redact(openrouter_api_key()), "openai_key": redact(openai_api_key()), + "novita_key": redact(novita_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(), @@ -516,6 +519,7 @@ def _cmd_models(args: argparse.Namespace) -> int: style.kv("backends", ", ".join(BACKENDS)), 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("ollama url", ollama_base_url(), tint=style.accent), "", style.heading("examples:"), diff --git a/grapharc/cli/probe.py b/grapharc/cli/probe.py index fa0936d..703c3b7 100644 --- a/grapharc/cli/probe.py +++ b/grapharc/cli/probe.py @@ -95,6 +95,26 @@ def _probe_openai() -> dict[str, Any]: } +def _probe_novita() -> dict[str, Any]: + from grapharc.gateway import novita_api_key, redact + + key = novita_api_key() + has_dependency = importlib.util.find_spec("langchain_openai") is not None + missing = [] + if not key: + missing.append("no API key (set NOVITA_API_KEY, or add one to .env)") + if not has_dependency: + missing.append("langchain-openai not installed (uv sync --extra novita)") + return { + "backend": "novita", + "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.novita.ai", + } + + def _probe_ollama() -> dict[str, Any]: from grapharc.gateway import ollama_base_url @@ -152,6 +172,7 @@ def probe_backends(*, claude_path: str = "claude") -> list[dict[str, Any]]: "claude-cli": lambda: _probe_claude_cli(claude_path), "openrouter": _probe_openrouter, "openai": _probe_openai, + "novita": _probe_novita, "ollama": _probe_ollama, "mock": _probe_mock, } diff --git a/grapharc/gateway/__init__.py b/grapharc/gateway/__init__.py index 2155d04..951264e 100644 --- a/grapharc/gateway/__init__.py +++ b/grapharc/gateway/__init__.py @@ -6,6 +6,7 @@ get_model("claude-cli/claude-sonnet-5") # subscription, no API key get_model("openrouter/anthropic/claude-sonnet-4.5") # many providers, one key get_model("openai/gpt-4o-mini") # OPENAI_API_KEY + get_model("novita/deepseek/deepseek-r1") # NOVITA_API_KEY get_model("ollama/llama3.1") # local server, no key get_model("mock/x", responses=[...]) # deterministic tests @@ -16,12 +17,13 @@ get_model(spec, cost_ceiling_usd=0.25) # raises when passed get_model(spec, spend=shared_meter) # one ceiling, many models -The three OpenAI-wire backends (`openrouter`, `openai`, `ollama`) are imported -lazily — they need `langchain-openai`, which is an optional extra. +The four OpenAI-wire backends (`openrouter`, `openai`, `novita`, `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 ( + novita_api_key, ollama_api_key, ollama_base_url, openai_api_key, @@ -66,6 +68,7 @@ "different_providers", "get_model", "is_transient", + "novita_api_key", "ollama_api_key", "ollama_base_url", "openai_api_key", @@ -85,6 +88,8 @@ "OpenRouterError": "openrouter", "OpenAIChatModel": "openai", "OpenAIError": "openai", + "NovitaChatModel": "novita", + "NovitaError": "novita", "OllamaChatModel": "ollama", "OllamaError": "ollama", } diff --git a/grapharc/gateway/config.py b/grapharc/gateway/config.py index f60b63d..b74d6e2 100644 --- a/grapharc/gateway/config.py +++ b/grapharc/gateway/config.py @@ -20,7 +20,7 @@ Secrets are returned, never logged. Anything that renders a config for humans goes through `redact`. -Three key-holding backends, plus one that usually holds none: +Four key-holding backends, plus one that usually holds none: - **OpenRouter** — `OPENROUTER_API_KEY`. - **OpenAI** — `OPENAI_API_KEY`, optionally with `OPENAI_BASE_URL` for an @@ -28,6 +28,9 @@ from the process environment on its own; going through here as well is what adds `.env` support, the alternate spellings, and a failure that names the variable instead of surfacing an SDK error. +- **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. - **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 @@ -56,6 +59,13 @@ "openai_api_key", ) +NOVITA_KEYS = ( + "NOVITA_API_KEY", + "NOVITA_KEY", + "novita-api-key", + "novita_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 = ( @@ -133,6 +143,10 @@ def openai_api_key(*, env_file: Path | None = None) -> str | None: return get_secret(OPENAI_KEYS, env_file=env_file) +def novita_api_key(*, env_file: Path | None = None) -> str | None: + return get_secret(NOVITA_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. diff --git a/grapharc/gateway/novita.py b/grapharc/gateway/novita.py new file mode 100644 index 0000000..af86487 --- /dev/null +++ b/grapharc/gateway/novita.py @@ -0,0 +1,59 @@ +"""Novita backend — a GPU cloud hosting open-weight models, one key. + +`novita/deepseek/deepseek-r1` reaches Novita's own OpenAI-compatible endpoint +(`https://api.novita.ai/openai`), not api.openai.com, so this builds on +`OpenAICompatChatModel` the same way `openrouter.py` and `ollama.py` do rather +than on `openai.py`: the endpoint is fixed, not an override of OpenAI's own. + +Model ids on Novita are themselves `author/slug` — `deepseek/deepseek-r1`, +`qwen/qwen3-235b-a22b` — the same shape OpenRouter uses, so `vendor()` in +`registry.py` already reads the right author off a Novita spec with no +backend-specific handling: `BACKEND_VENDOR` stays absent for `novita`, exactly +as it is absent for `openrouter`. + +**Novita reports no per-call cost.** Unlike OpenRouter, the chat-completions +response carries token counts and nothing else, so this backend is the OpenAI +one in that respect: `_provider_cost` is the base class's `None`, and +`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 novita_api_key +from grapharc.gateway.openai_compat import OpenAICompatChatModel + +NOVITA_BASE_URL = "https://api.novita.ai/openai" + + +class NovitaError(Exception): + """The Novita backend could not be constructed or used.""" + + +class NovitaChatModel(OpenAICompatChatModel): + """A LangChain chat model over Novita's OpenAI-compatible endpoint.""" + + def __init__(self, model: str, /, **kwargs: Any) -> None: + api_key = kwargs.pop("api_key", None) or novita_api_key() + if not api_key: + raise NovitaError( + "No Novita API key found. Set NOVITA_API_KEY in the environment, " + "or add one of NOVITA_API_KEY / novita-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 NOVITA_BASE_URL, + **kwargs, + ) + + @property + def _llm_type(self) -> str: + return "grapharc-novita" + + +__all__ = ["NOVITA_BASE_URL", "NovitaChatModel", "NovitaError"] diff --git a/grapharc/gateway/registry.py b/grapharc/gateway/registry.py index a581568..258e3cd 100644 --- a/grapharc/gateway/registry.py +++ b/grapharc/gateway/registry.py @@ -7,6 +7,7 @@ openrouter/anthropic/claude-sonnet-4.5 -> OpenRouter openrouter/openai/gpt-4o:floor -> OpenRouter, cheapest provider openai/gpt-4o-mini -> OpenAI directly (OPENAI_API_KEY) + novita/deepseek/deepseek-r1 -> Novita (NOVITA_API_KEY) ollama/llama3.1 -> a local Ollama server, no key mock/anything -> scripted test double @@ -28,7 +29,7 @@ class UnknownBackendError(Exception): """The spec named a backend that is not registered.""" -BACKENDS = ("claude-cli", "openrouter", "openai", "ollama", "mock") +BACKENDS = ("claude-cli", "openrouter", "openai", "novita", "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` @@ -71,6 +72,7 @@ class UnknownBackendError(Exception): _BARE_BACKEND_EXAMPLE = { "openrouter": "openrouter/anthropic/claude-sonnet-4.5", "openai": "openai/gpt-4o-mini", + "novita": "novita/deepseek/deepseek-r1", "ollama": "ollama/llama3.1", } @@ -160,6 +162,17 @@ def get_model(spec: str, **kwargs: Any) -> BaseChatModel: return OpenAIChatModel(model, **kwargs) + if backend == "novita": + try: + from grapharc.gateway.novita import NovitaChatModel + except ImportError as exc: # pragma: no cover - depends on install extras + raise UnknownBackendError( + "The Novita backend needs langchain-openai. " + "Install it with: uv sync --extra novita" + ) from exc + + return NovitaChatModel(model, **kwargs) + if backend == "ollama": try: from grapharc.gateway.ollama import OllamaChatModel diff --git a/pyproject.toml b/pyproject.toml index 8599cce..eb507aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,12 @@ openrouter = [ openai = [ "langchain-openai>=0.2", ] +# Imported by grapharc/gateway/novita.py. Novita speaks the OpenAI wire format +# against its own endpoint, so this needs the same client and no Novita-specific +# package. +novita = [ + "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 = [ @@ -110,7 +116,7 @@ slack = [ ] # Everything above. Self-referential so it cannot drift out of sync. all = [ - "grapharc[api,ladybug,mcp,ollama,openai,openrouter,otel,server,slack]", + "grapharc[api,ladybug,mcp,novita,ollama,openai,openrouter,otel,server,slack]", ] [dependency-groups] diff --git a/tests/test_cli.py b/tests/test_cli.py index f3c8ac1..602974a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -438,6 +438,7 @@ def test_models_check_exits_one_when_nothing_is_configured(monkeypatch, capsys): for name in ( "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", "OLLAMA_HOST", "OLLAMA_BASE_URL", ): monkeypatch.delenv(name, raising=False) @@ -452,10 +453,11 @@ def test_models_check_exits_one_when_nothing_is_configured(monkeypatch, capsys): "claude-cli": False, "openrouter": False, "openai": False, + "novita": False, "ollama": False, "mock": True, } - for backend in ("openrouter", "openai"): + for backend in ("openrouter", "openai", "novita"): assert next(b for b in payload["backends"] if b["backend"] == backend)[ "credential" ] == "" diff --git a/tests/test_cookbook_models.py b/tests/test_cookbook_models.py index 18b5b68..5ed5551 100644 --- a/tests/test_cookbook_models.py +++ b/tests/test_cookbook_models.py @@ -41,6 +41,7 @@ from grapharc.gateway import DEFAULT_RETRY_POLICY, NO_RETRY from grapharc.gateway.config import ( + NOVITA_KEYS, OLLAMA_BASE_URL_KEYS, OLLAMA_KEYS, OPENAI_BASE_URL_KEYS, @@ -72,7 +73,14 @@ # as a machine with nothing configured, or `grapharc models` prints a # fingerprint where the page shows ``. _CREDENTIAL_ENV = frozenset( - (*OPENROUTER_KEYS, *OPENAI_KEYS, *OPENAI_BASE_URL_KEYS, *OLLAMA_KEYS, *OLLAMA_BASE_URL_KEYS) + ( + *OPENROUTER_KEYS, + *OPENAI_KEYS, + *OPENAI_BASE_URL_KEYS, + *NOVITA_KEYS, + *OLLAMA_KEYS, + *OLLAMA_BASE_URL_KEYS, + ) ) @@ -185,7 +193,13 @@ def _needs_openrouter(body: str) -> bool: """ return any( marker in body - for marker in ("openrouter", "langchain_openai", '"openai/', '"ollama/') + for marker in ( + "openrouter", + "langchain_openai", + '"openai/', + '"novita/', + '"ollama/', + ) ) @@ -278,9 +292,9 @@ def test_cli_command_with_machine_dependent_output_runs(position, tmp_path): def test_the_backend_list_the_page_prints_is_the_real_one(): page = DOC.read_text(encoding="utf-8") - assert BACKENDS == ("claude-cli", "openrouter", "openai", "ollama", "mock") + assert BACKENDS == ("claude-cli", "openrouter", "openai", "novita", "ollama", "mock") assert DEFAULT_BACKEND == "claude-cli" - assert "There are five backends:" in page + assert "There are six backends:" in page # Every backend has a row in the page's table. A backend added to the # gateway and not to the page fails here rather than going undocumented. for backend in BACKENDS: diff --git a/tests/test_gateway_novita.py b/tests/test_gateway_novita.py new file mode 100644 index 0000000..b7aa705 --- /dev/null +++ b/tests/test_gateway_novita.py @@ -0,0 +1,209 @@ +"""The Novita backend. + +Everything here is offline: constructing a `ChatOpenAI` opens no socket, so the +wiring — credentials, endpoint, cost accounting — is checkable without +spending anything. +""" + +from __future__ import annotations + +import pytest +from langchain_core.messages import AIMessage +from langchain_core.outputs import ChatGeneration, ChatResult + +from grapharc.gateway import config, describe, get_model, split_spec, vendor +from grapharc.gateway.registry import UnknownBackendError + +pytest.importorskip("langchain_openai", reason="Novita needs the novita extra") + +from grapharc.gateway.novita import NOVITA_BASE_URL, NovitaChatModel, NovitaError # noqa: E402 + + +@pytest.fixture +def no_credentials(monkeypatch, tmp_path): + """No key in the environment, and a working directory holding no .env.""" + for name in config.NOVITA_KEYS: + monkeypatch.delenv(name, raising=False) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def _result(**token_usage) -> ChatResult: + return ChatResult( + generations=[ChatGeneration(message=AIMessage(content="x"))], + llm_output={"model_name": "test-model", "token_usage": token_usage}, + ) + + +# ---------------------------------------------------------------- credentials + + +def test_novita_key_is_read_from_a_dotenv_file(tmp_path, monkeypatch): + """`novita-api-key` cannot be a shell variable, so the file is parsed.""" + for name in config.NOVITA_KEYS: + monkeypatch.delenv(name, raising=False) + env = tmp_path / ".env" + env.write_text('novita-api-key="sk-fromfile"\n', encoding="utf-8") + assert config.novita_api_key(env_file=env) == "sk-fromfile" + + +def test_novita_process_env_beats_the_file(tmp_path, monkeypatch): + env = tmp_path / ".env" + env.write_text("novita-api-key=from-file\n", encoding="utf-8") + monkeypatch.setenv("NOVITA_API_KEY", "from-env") + assert config.novita_api_key(env_file=env) == "from-env" + + +def test_constructing_novita_without_a_key_explains_how_to_fix_it(no_credentials): + with pytest.raises(NovitaError, match="NOVITA_API_KEY"): + NovitaChatModel("deepseek/deepseek-r1") + + +def test_novita_key_never_appears_in_a_description_or_a_redaction(monkeypatch): + secret = "sk-novita-0123456789abcdef0123456789abcdef" + monkeypatch.setenv("NOVITA_API_KEY", secret) + assert secret not in str(describe("novita/deepseek/deepseek-r1")) + assert secret not in config.redact(secret) + + +def test_novita_always_points_at_its_own_endpoint(monkeypatch): + """Unlike OpenAI, there is no override — the endpoint is fixed.""" + monkeypatch.setenv("NOVITA_API_KEY", "sk-test") + model = NovitaChatModel("deepseek/deepseek-r1") + assert str(model.openai_api_base) == NOVITA_BASE_URL + assert NOVITA_BASE_URL == "https://api.novita.ai/openai" + + +# ------------------------------------------------------------------- registry + + +@pytest.mark.parametrize( + ("spec", "backend", "model"), + [ + ("novita/deepseek/deepseek-r1", "novita", "deepseek/deepseek-r1"), + ("novita/qwen/qwen3-235b-a22b", "novita", "qwen/qwen3-235b-a22b"), + ], +) +def test_novita_specs_split_the_way_the_docs_say(spec, backend, model): + assert split_spec(spec) == (backend, model) + assert describe(spec) == {"spec": spec, "backend": backend, "model": model} + + +def test_the_registry_builds_the_novita_backend(monkeypatch): + monkeypatch.setenv("NOVITA_API_KEY", "sk-test") + assert get_model("novita/deepseek/deepseek-r1")._llm_type == "grapharc-novita" + + +def test_a_missing_novita_key_surfaces_through_the_registry(no_credentials): + with pytest.raises(NovitaError): + get_model("novita/deepseek/deepseek-r1") + + +def test_an_unknown_backend_still_names_novita(): + with pytest.raises(UnknownBackendError, match="novita"): + get_model("nvita/deepseek/deepseek-r1") + + +def test_vendor_reads_the_model_authors_novita_ids_carry(): + """Novita ids are themselves `author/slug`, the same shape OpenRouter uses, + so `vendor()` needs no Novita-specific entry in `BACKEND_VENDOR`.""" + assert vendor("novita/deepseek/deepseek-r1") == "deepseek" + assert vendor("novita/qwen/qwen3-235b-a22b") == "qwen" + + +# ------------------------------------------------- capabilities and accounting + + +def test_novita_can_bind_tools_and_structure_output(monkeypatch): + """The capability the Claude-CLI backend cannot offer.""" + from langchain_core.tools import tool + from pydantic import BaseModel + + @tool + def get_weather(city: str) -> str: + """Get the current weather for a city.""" + return f"sunny in {city}" + + class Verdict(BaseModel): + supported: bool + + monkeypatch.setenv("NOVITA_API_KEY", "sk-test") + model = NovitaChatModel("deepseek/deepseek-r1") + model.bind_tools([get_weather]) + model.with_structured_output(Verdict) + + +def test_the_usage_envelope_matches_every_other_backend(monkeypatch): + monkeypatch.setenv("NOVITA_API_KEY", "sk-test") + model = NovitaChatModel("deepseek/deepseek-r1") + model._record_usage( + _result( + prompt_tokens=1000, + completion_tokens=50, + prompt_tokens_details={"cached_tokens": 800}, + ) + ) + usage = model.last_usage + assert usage["input_tokens"] == 1000 # cached input still counts + assert usage["total_tokens"] == 1050 + assert usage["input_token_details"]["cache_read"] == 800 + assert usage["uncached_input_tokens"] == 200 + + +def test_novita_reports_no_cost_and_says_so_rather_than_guessing(monkeypatch): + """Like the OpenAI API, Novita's response carries tokens and no price. An + invented number would be worse than an admitted gap, so the call is + counted as unpriced.""" + monkeypatch.setenv("NOVITA_API_KEY", "sk-test") + model = NovitaChatModel("deepseek/deepseek-r1") + model._settle(_result(prompt_tokens=1000, completion_tokens=50)) + assert model.last_usage["cost_usd"] is None + assert model.spend.unpriced_calls == 1 + assert model.spend.spent_usd == 0.0 + + +def test_a_rate_card_prices_novita_the_way_it_prices_openai(monkeypatch): + monkeypatch.setenv("NOVITA_API_KEY", "sk-test") + model = NovitaChatModel( + "deepseek/deepseek-r1", + price_per_million={"input": 0.15, "cached_input": 0.075, "output": 0.60}, + ) + model._settle( + _result( + prompt_tokens=1_000_000, + completion_tokens=1_000_000, + prompt_tokens_details={"cached_tokens": 400_000}, + ) + ) + # 600k uncached @ 0.15 + 400k cached @ 0.075 + 1M output @ 0.60 + assert model.last_usage["cost_usd"] == pytest.approx(0.09 + 0.03 + 0.60) + assert model.spend.unpriced_calls == 0 + + +def test_novita_sets_no_max_tokens_ceiling_of_its_own(monkeypatch): + """OpenRouter defaults it to dodge a credit-reservation 402. Novita has no + such reservation, so a default here would only truncate replies.""" + monkeypatch.setenv("NOVITA_API_KEY", "sk-test") + assert NovitaChatModel("deepseek/deepseek-r1").max_tokens is None + + +# ------------------------------------------------------------------ live ---- + + +@pytest.mark.live +@pytest.mark.skipif(not config.novita_api_key(), reason="no Novita API key configured") +def test_live_novita_tool_calling(): + from langchain_core.messages import HumanMessage + from langchain_core.tools import tool + + @tool + def get_weather(city: str) -> str: + """Get the current weather for a city.""" + return f"sunny in {city}" + + model = get_model("novita/deepseek/deepseek-r1", temperature=0, max_tokens=512) + reply = model.bind_tools([get_weather]).invoke( + [HumanMessage(content="What is the weather in Paris? Use the tool.")] + ) + assert reply.tool_calls + assert reply.tool_calls[0]["name"] == "get_weather" diff --git a/tests/test_gateway_openai_ollama.py b/tests/test_gateway_openai_ollama.py index 823dc96..82fd5fb 100644 --- a/tests/test_gateway_openai_ollama.py +++ b/tests/test_gateway_openai_ollama.py @@ -175,7 +175,7 @@ def test_a_missing_openai_key_surfaces_through_the_registry(no_credentials): def test_an_unknown_backend_still_names_every_real_one(): - with pytest.raises(UnknownBackendError, match="openai, ollama"): + with pytest.raises(UnknownBackendError, match="openai, novita, ollama"): get_model("opnai/gpt-4o-mini") diff --git a/tests/test_gateway_openrouter.py b/tests/test_gateway_openrouter.py index 29d7e89..5d4a54c 100644 --- a/tests/test_gateway_openrouter.py +++ b/tests/test_gateway_openrouter.py @@ -148,7 +148,9 @@ def test_spec_splitting_keeps_openrouter_author_slugs_intact(spec, backend, mode def test_a_typoed_backend_is_caught_early_not_folded_into_a_model_name(): """`opnerouter/...` must fail here, not become a Claude-CLI call with a nonsense model that errors confusingly much later.""" - with pytest.raises(UnknownBackendError, match="claude-cli, openrouter, openai, ollama, mock"): + with pytest.raises( + UnknownBackendError, match="claude-cli, openrouter, openai, novita, ollama, mock" + ): split_spec("opnerouter/anthropic/claude-haiku-4.5") with pytest.raises(UnknownBackendError): get_model("nope/some-model") diff --git a/uv.lock b/uv.lock index 2a6f8b2..0bacb2a 100644 --- a/uv.lock +++ b/uv.lock @@ -381,6 +381,9 @@ ladybug = [ mcp = [ { name = "mcp" }, ] +novita = [ + { name = "langchain-openai" }, +] ollama = [ { name = "langchain-openai" }, ] @@ -416,8 +419,9 @@ dev = [ requires-dist = [ { name = "anthropic", marker = "extra == 'api'", specifier = ">=0.40" }, { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115" }, - { name = "grapharc", extras = ["api", "ladybug", "mcp", "ollama", "openai", "openrouter", "otel", "server", "slack"], marker = "extra == 'all'" }, + { name = "grapharc", extras = ["api", "ladybug", "mcp", "novita", "ollama", "openai", "openrouter", "otel", "server", "slack"], marker = "extra == 'all'" }, { name = "langchain-core", specifier = ">=0.3" }, + { name = "langchain-openai", marker = "extra == 'novita'", specifier = ">=0.2" }, { name = "langchain-openai", marker = "extra == 'ollama'", specifier = ">=0.2" }, { name = "langchain-openai", marker = "extra == 'openai'", specifier = ">=0.2" }, { name = "langchain-openai", marker = "extra == 'openrouter'", specifier = ">=0.2" }, @@ -432,7 +436,7 @@ requires-dist = [ { name = "slack-bolt", marker = "extra == 'slack'", specifier = ">=1.20" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.32" }, ] -provides-extras = ["openrouter", "openai", "ollama", "server", "mcp", "ladybug", "otel", "api", "slack", "all"] +provides-extras = ["openrouter", "openai", "novita", "ollama", "server", "mcp", "ladybug", "otel", "api", "slack", "all"] [package.metadata.requires-dev] dev = [ From 8f177daa59ea85ca753cbe62f1074ada14dc931f Mon Sep 17 00:00:00 2001 From: jax-novita Date: Wed, 5 Aug 2026 21:08:10 +0800 Subject: [PATCH 2/3] Recommend Novita's current flagship models The models listed for Novita were older ids that no longer reflect what the platform leads with. Point the recommendations at the three current flagships instead, each verified against api.novita.ai: moonshotai/kimi-k3 1M context, native vision zai-org/glm-5.2 1M context, long-horizon agentic work deepseek/deepseek-v4-flash-0731 1M context, cheapest of the three Context windows, output limits, input modalities and pricing were taken from the live /openai/v1/models response rather than carried over. --- docs/cookbook/02-models.md | 6 +++--- grapharc/cli/main.py | 2 +- grapharc/gateway/__init__.py | 2 +- grapharc/gateway/novita.py | 6 +++--- grapharc/gateway/registry.py | 4 ++-- tests/test_gateway_novita.py | 32 ++++++++++++++++---------------- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/cookbook/02-models.md b/docs/cookbook/02-models.md index 10e87fa..9998d42 100644 --- a/docs/cookbook/02-models.md +++ b/docs/cookbook/02-models.md @@ -24,7 +24,7 @@ for spec in ( "claude-cli/claude-sonnet-5", "openrouter/anthropic/claude-haiku-4.5", "openai/gpt-4o-mini", - "novita/deepseek/deepseek-r1", + "novita/moonshotai/kimi-k3", "ollama/llama3.1", "mock/anything", ): @@ -39,7 +39,7 @@ print(model.invoke("say hi").content) {'spec': 'claude-cli/claude-sonnet-5', 'backend': 'claude-cli', 'model': 'claude-sonnet-5'} {'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/deepseek/deepseek-r1', 'backend': 'novita', 'model': 'deepseek/deepseek-r1'} +{'spec': 'novita/moonshotai/kimi-k3', 'backend': 'novita', 'model': 'moonshotai/kimi-k3'} {'spec': 'ollama/llama3.1', 'backend': 'ollama', 'model': 'llama3.1'} {'spec': 'mock/anything', 'backend': 'mock', 'model': 'anything'} hello from a scripted model @@ -136,7 +136,7 @@ examples: openrouter/anthropic/claude-haiku-4.5 many providers, one key openrouter/openai/gpt-4o-mini:floor cheapest provider for that model openai/gpt-4o-mini the OpenAI API directly, your key - novita/deepseek/deepseek-r1 Novita's own endpoint, your key + novita/moonshotai/kimi-k3 Novita'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 diff --git a/grapharc/cli/main.py b/grapharc/cli/main.py index 532dc60..e5cc9d3 100644 --- a/grapharc/cli/main.py +++ b/grapharc/cli/main.py @@ -498,7 +498,7 @@ def _cmd_models(args: argparse.Namespace) -> int: "openrouter/anthropic/claude-haiku-4.5": "many providers, one key", "openrouter/openai/gpt-4o-mini:floor": "cheapest provider for that model", "openai/gpt-4o-mini": "the OpenAI API directly, your key", - "novita/deepseek/deepseek-r1": "Novita's own endpoint, your key", + "novita/moonshotai/kimi-k3": "Novita's own endpoint, your key", "ollama/llama3.1": "a local server, no key and no bill", } payload = { diff --git a/grapharc/gateway/__init__.py b/grapharc/gateway/__init__.py index 951264e..47a9c14 100644 --- a/grapharc/gateway/__init__.py +++ b/grapharc/gateway/__init__.py @@ -6,7 +6,7 @@ get_model("claude-cli/claude-sonnet-5") # subscription, no API key get_model("openrouter/anthropic/claude-sonnet-4.5") # many providers, one key get_model("openai/gpt-4o-mini") # OPENAI_API_KEY - get_model("novita/deepseek/deepseek-r1") # NOVITA_API_KEY + get_model("novita/moonshotai/kimi-k3") # NOVITA_API_KEY get_model("ollama/llama3.1") # local server, no key get_model("mock/x", responses=[...]) # deterministic tests diff --git a/grapharc/gateway/novita.py b/grapharc/gateway/novita.py index af86487..1874f0c 100644 --- a/grapharc/gateway/novita.py +++ b/grapharc/gateway/novita.py @@ -1,12 +1,12 @@ """Novita backend — a GPU cloud hosting open-weight models, one key. -`novita/deepseek/deepseek-r1` reaches Novita's own OpenAI-compatible endpoint +`novita/moonshotai/kimi-k3` reaches Novita's own OpenAI-compatible endpoint (`https://api.novita.ai/openai`), not api.openai.com, so this builds on `OpenAICompatChatModel` the same way `openrouter.py` and `ollama.py` do rather than on `openai.py`: the endpoint is fixed, not an override of OpenAI's own. -Model ids on Novita are themselves `author/slug` — `deepseek/deepseek-r1`, -`qwen/qwen3-235b-a22b` — the same shape OpenRouter uses, so `vendor()` in +Model ids on Novita are themselves `author/slug` — `moonshotai/kimi-k3`, +`zai-org/glm-5.2` — the same shape OpenRouter uses, so `vendor()` in `registry.py` already reads the right author off a Novita spec with no backend-specific handling: `BACKEND_VENDOR` stays absent for `novita`, exactly as it is absent for `openrouter`. diff --git a/grapharc/gateway/registry.py b/grapharc/gateway/registry.py index 258e3cd..3338669 100644 --- a/grapharc/gateway/registry.py +++ b/grapharc/gateway/registry.py @@ -7,7 +7,7 @@ openrouter/anthropic/claude-sonnet-4.5 -> OpenRouter openrouter/openai/gpt-4o:floor -> OpenRouter, cheapest provider openai/gpt-4o-mini -> OpenAI directly (OPENAI_API_KEY) - novita/deepseek/deepseek-r1 -> Novita (NOVITA_API_KEY) + novita/moonshotai/kimi-k3 -> Novita (NOVITA_API_KEY) ollama/llama3.1 -> a local Ollama server, no key mock/anything -> scripted test double @@ -72,7 +72,7 @@ class UnknownBackendError(Exception): _BARE_BACKEND_EXAMPLE = { "openrouter": "openrouter/anthropic/claude-sonnet-4.5", "openai": "openai/gpt-4o-mini", - "novita": "novita/deepseek/deepseek-r1", + "novita": "novita/moonshotai/kimi-k3", "ollama": "ollama/llama3.1", } diff --git a/tests/test_gateway_novita.py b/tests/test_gateway_novita.py index b7aa705..784eae7 100644 --- a/tests/test_gateway_novita.py +++ b/tests/test_gateway_novita.py @@ -56,20 +56,20 @@ def test_novita_process_env_beats_the_file(tmp_path, monkeypatch): def test_constructing_novita_without_a_key_explains_how_to_fix_it(no_credentials): with pytest.raises(NovitaError, match="NOVITA_API_KEY"): - NovitaChatModel("deepseek/deepseek-r1") + NovitaChatModel("moonshotai/kimi-k3") def test_novita_key_never_appears_in_a_description_or_a_redaction(monkeypatch): secret = "sk-novita-0123456789abcdef0123456789abcdef" monkeypatch.setenv("NOVITA_API_KEY", secret) - assert secret not in str(describe("novita/deepseek/deepseek-r1")) + assert secret not in str(describe("novita/moonshotai/kimi-k3")) assert secret not in config.redact(secret) def test_novita_always_points_at_its_own_endpoint(monkeypatch): """Unlike OpenAI, there is no override — the endpoint is fixed.""" monkeypatch.setenv("NOVITA_API_KEY", "sk-test") - model = NovitaChatModel("deepseek/deepseek-r1") + model = NovitaChatModel("moonshotai/kimi-k3") assert str(model.openai_api_base) == NOVITA_BASE_URL assert NOVITA_BASE_URL == "https://api.novita.ai/openai" @@ -80,8 +80,8 @@ def test_novita_always_points_at_its_own_endpoint(monkeypatch): @pytest.mark.parametrize( ("spec", "backend", "model"), [ - ("novita/deepseek/deepseek-r1", "novita", "deepseek/deepseek-r1"), - ("novita/qwen/qwen3-235b-a22b", "novita", "qwen/qwen3-235b-a22b"), + ("novita/moonshotai/kimi-k3", "novita", "moonshotai/kimi-k3"), + ("novita/zai-org/glm-5.2", "novita", "zai-org/glm-5.2"), ], ) def test_novita_specs_split_the_way_the_docs_say(spec, backend, model): @@ -91,24 +91,24 @@ def test_novita_specs_split_the_way_the_docs_say(spec, backend, model): def test_the_registry_builds_the_novita_backend(monkeypatch): monkeypatch.setenv("NOVITA_API_KEY", "sk-test") - assert get_model("novita/deepseek/deepseek-r1")._llm_type == "grapharc-novita" + assert get_model("novita/moonshotai/kimi-k3")._llm_type == "grapharc-novita" def test_a_missing_novita_key_surfaces_through_the_registry(no_credentials): with pytest.raises(NovitaError): - get_model("novita/deepseek/deepseek-r1") + get_model("novita/moonshotai/kimi-k3") def test_an_unknown_backend_still_names_novita(): with pytest.raises(UnknownBackendError, match="novita"): - get_model("nvita/deepseek/deepseek-r1") + get_model("nvita/moonshotai/kimi-k3") def test_vendor_reads_the_model_authors_novita_ids_carry(): """Novita ids are themselves `author/slug`, the same shape OpenRouter uses, so `vendor()` needs no Novita-specific entry in `BACKEND_VENDOR`.""" - assert vendor("novita/deepseek/deepseek-r1") == "deepseek" - assert vendor("novita/qwen/qwen3-235b-a22b") == "qwen" + assert vendor("novita/moonshotai/kimi-k3") == "moonshotai" + assert vendor("novita/zai-org/glm-5.2") == "zai-org" # ------------------------------------------------- capabilities and accounting @@ -128,14 +128,14 @@ class Verdict(BaseModel): supported: bool monkeypatch.setenv("NOVITA_API_KEY", "sk-test") - model = NovitaChatModel("deepseek/deepseek-r1") + model = NovitaChatModel("moonshotai/kimi-k3") model.bind_tools([get_weather]) model.with_structured_output(Verdict) def test_the_usage_envelope_matches_every_other_backend(monkeypatch): monkeypatch.setenv("NOVITA_API_KEY", "sk-test") - model = NovitaChatModel("deepseek/deepseek-r1") + model = NovitaChatModel("moonshotai/kimi-k3") model._record_usage( _result( prompt_tokens=1000, @@ -155,7 +155,7 @@ def test_novita_reports_no_cost_and_says_so_rather_than_guessing(monkeypatch): invented number would be worse than an admitted gap, so the call is counted as unpriced.""" monkeypatch.setenv("NOVITA_API_KEY", "sk-test") - model = NovitaChatModel("deepseek/deepseek-r1") + model = NovitaChatModel("moonshotai/kimi-k3") model._settle(_result(prompt_tokens=1000, completion_tokens=50)) assert model.last_usage["cost_usd"] is None assert model.spend.unpriced_calls == 1 @@ -165,7 +165,7 @@ def test_novita_reports_no_cost_and_says_so_rather_than_guessing(monkeypatch): def test_a_rate_card_prices_novita_the_way_it_prices_openai(monkeypatch): monkeypatch.setenv("NOVITA_API_KEY", "sk-test") model = NovitaChatModel( - "deepseek/deepseek-r1", + "moonshotai/kimi-k3", price_per_million={"input": 0.15, "cached_input": 0.075, "output": 0.60}, ) model._settle( @@ -184,7 +184,7 @@ def test_novita_sets_no_max_tokens_ceiling_of_its_own(monkeypatch): """OpenRouter defaults it to dodge a credit-reservation 402. Novita has no such reservation, so a default here would only truncate replies.""" monkeypatch.setenv("NOVITA_API_KEY", "sk-test") - assert NovitaChatModel("deepseek/deepseek-r1").max_tokens is None + assert NovitaChatModel("moonshotai/kimi-k3").max_tokens is None # ------------------------------------------------------------------ live ---- @@ -201,7 +201,7 @@ def get_weather(city: str) -> str: """Get the current weather for a city.""" return f"sunny in {city}" - model = get_model("novita/deepseek/deepseek-r1", temperature=0, max_tokens=512) + model = get_model("novita/moonshotai/kimi-k3", temperature=0, max_tokens=512) reply = model.bind_tools([get_weather]).invoke( [HumanMessage(content="What is the weather in Paris? Use the tool.")] ) From 2afecffe176892cbc9dca7b10fd5cc54a7aa233a Mon Sep 17 00:00:00 2001 From: jax-novita Date: Tue, 11 Aug 2026 07:21:24 +0800 Subject: [PATCH 3/3] Fix column alignment for novita example in models cookbook --- docs/cookbook/02-models.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cookbook/02-models.md b/docs/cookbook/02-models.md index 9998d42..573f64f 100644 --- a/docs/cookbook/02-models.md +++ b/docs/cookbook/02-models.md @@ -136,7 +136,7 @@ examples: openrouter/anthropic/claude-haiku-4.5 many providers, one key 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 + novita/moonshotai/kimi-k3 Novita'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