Skip to content

Commit 6b7ad91

Browse files
authored
Add Novita as a model gateway backend (#102)
* 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. * 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. * Fix column alignment for novita example in models cookbook --------- Co-authored-by: jax-novita <jax-novita@users.noreply.github.com>
1 parent 75efd98 commit 6b7ad91

14 files changed

Lines changed: 379 additions & 19 deletions

docs/cookbook/02-models.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ for spec in (
2424
"claude-cli/claude-sonnet-5",
2525
"openrouter/anthropic/claude-haiku-4.5",
2626
"openai/gpt-4o-mini",
27+
"novita/moonshotai/kimi-k3",
2728
"ollama/llama3.1",
2829
"mock/anything",
2930
):
@@ -38,18 +39,20 @@ print(model.invoke("say hi").content)
3839
{'spec': 'claude-cli/claude-sonnet-5', 'backend': 'claude-cli', 'model': 'claude-sonnet-5'}
3940
{'spec': 'openrouter/anthropic/claude-haiku-4.5', 'backend': 'openrouter', 'model': 'anthropic/claude-haiku-4.5'}
4041
{'spec': 'openai/gpt-4o-mini', 'backend': 'openai', 'model': 'gpt-4o-mini'}
42+
{'spec': 'novita/moonshotai/kimi-k3', 'backend': 'novita', 'model': 'moonshotai/kimi-k3'}
4143
{'spec': 'ollama/llama3.1', 'backend': 'ollama', 'model': 'llama3.1'}
4244
{'spec': 'mock/anything', 'backend': 'mock', 'model': 'anything'}
4345
hello from a scripted model
4446
```
4547

46-
There are five backends:
48+
There are six backends:
4749

4850
| backend | credential | what it is for |
4951
| --- | --- | --- |
5052
| `claude-cli` | a Claude subscription, no API key | text completion on quota you already pay for |
5153
| `openrouter` | `OPENROUTER_API_KEY` | one key, most vendors, per-call cost in the response |
5254
| `openai` | `OPENAI_API_KEY` | the OpenAI API directly, or any endpoint via `OPENAI_BASE_URL` |
55+
| `novita` | `NOVITA_API_KEY` | Novita's own endpoint, token counts but no per-call cost |
5356
| `ollama` | none — a local server | models on your own machine, free and offline |
5457
| `mock` | none | a scripted test double |
5558

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

66-
`openrouter`, `openai` and `ollama` all speak the OpenAI wire format and share one base
67-
class, so they behave identically on everything except money and routing: same
69+
`openrouter`, `openai`, `novita` and `ollama` all speak the OpenAI wire format and share one
70+
base class, so they behave identically on everything except money and routing: same
6871
`bind_tools`, same `with_structured_output`, same streaming and async, same retry policy,
6972
same usage envelope.
7073

@@ -93,7 +96,7 @@ except UnknownBackendError as exc:
9396
('openrouter', 'openai/gpt-4o-mini:floor')
9497
('claude-cli', 'anthropic/claude-haiku-4.5')
9598
('openai', 'gpt-4o-mini')
96-
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
99+
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
97100
```
98101

99102
Only the first segment is a backend, because OpenRouter model ids are themselves
@@ -122,16 +125,18 @@ model through the broker is still `openrouter/openai/gpt-4o-mini`.
122125
<!-- verified: cli -->
123126
```console
124127
$ grapharc models
125-
backends: claude-cli, openrouter, openai, ollama, mock
128+
backends: claude-cli, openrouter, openai, novita, ollama, mock
126129
openrouter key: <unset>
127130
openai key: <unset>
131+
novita key: <unset>
128132
ollama url: http://localhost:11434/v1
129133

130134
examples:
131135
claude-cli/claude-sonnet-5 subscription, no API key
132136
openrouter/anthropic/claude-haiku-4.5 many providers, one key
133137
openrouter/openai/gpt-4o-mini:floor cheapest provider for that model
134138
openai/gpt-4o-mini the OpenAI API directly, your key
139+
novita/moonshotai/kimi-k3 Novita's own endpoint, your key
135140
ollama/llama3.1 a local server, no key and no bill
136141

137142
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)
165170
credential: <unset>
166171
openai unusable no API key (set OPENAI_API_KEY, or add one to .env)
167172
credential: <unset>
173+
novita unusable no API key (set NOVITA_API_KEY, or add one to .env)
174+
credential: <unset>
168175
ollama usable local server at http://localhost:11434/v1
169176
credential: none needed (local server)
170177
mock usable scripted test double; never reaches a provider

grapharc/cli/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ def _cmd_approve(args: argparse.Namespace) -> int:
463463
def _cmd_models(args: argparse.Namespace) -> int:
464464
from grapharc.gateway import (
465465
describe,
466+
novita_api_key,
466467
ollama_base_url,
467468
openai_api_key,
468469
openrouter_api_key,
@@ -511,6 +512,7 @@ def _cmd_models(args: argparse.Namespace) -> int:
511512
"openrouter/anthropic/claude-haiku-4.5": "many providers, one key",
512513
"openrouter/openai/gpt-4o-mini:floor": "cheapest provider for that model",
513514
"openai/gpt-4o-mini": "the OpenAI API directly, your key",
515+
"novita/moonshotai/kimi-k3": "Novita's own endpoint, your key",
514516
"ollama/llama3.1": "a local server, no key and no bill",
515517
}
516518
payload = {
@@ -519,6 +521,7 @@ def _cmd_models(args: argparse.Namespace) -> int:
519521
"backends": list(BACKENDS),
520522
"openrouter_key": redact(openrouter_api_key()),
521523
"openai_key": redact(openai_api_key()),
524+
"novita_key": redact(novita_api_key()),
522525
# An address, not a secret: it is printed whole, and it is where a
523526
# request would go rather than proof that anything is listening.
524527
"ollama_base_url": ollama_base_url(),
@@ -530,6 +533,7 @@ def _cmd_models(args: argparse.Namespace) -> int:
530533
style.kv("backends", ", ".join(BACKENDS)),
531534
style.kv("openrouter key", redact(openrouter_api_key())),
532535
style.kv("openai key", redact(openai_api_key())),
536+
style.kv("novita key", redact(novita_api_key())),
533537
style.kv("ollama url", ollama_base_url(), tint=style.accent),
534538
"",
535539
style.heading("examples:"),

grapharc/cli/probe.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,26 @@ def _probe_openai() -> dict[str, Any]:
9595
}
9696

9797

98+
def _probe_novita() -> dict[str, Any]:
99+
from grapharc.gateway import novita_api_key, redact
100+
101+
key = novita_api_key()
102+
has_dependency = importlib.util.find_spec("langchain_openai") is not None
103+
missing = []
104+
if not key:
105+
missing.append("no API key (set NOVITA_API_KEY, or add one to .env)")
106+
if not has_dependency:
107+
missing.append("langchain-openai not installed (uv sync --extra novita)")
108+
return {
109+
"backend": "novita",
110+
"kind": KIND_PROVIDER,
111+
"usable": bool(key) and has_dependency,
112+
"credential": redact(key),
113+
"detail": "; ".join(missing) or "api key configured and langchain-openai installed",
114+
"checked": "credential presence only; no request was sent to api.novita.ai",
115+
}
116+
117+
98118
def _probe_ollama() -> dict[str, Any]:
99119
from grapharc.gateway import ollama_base_url
100120

@@ -152,6 +172,7 @@ def probe_backends(*, claude_path: str = "claude") -> list[dict[str, Any]]:
152172
"claude-cli": lambda: _probe_claude_cli(claude_path),
153173
"openrouter": _probe_openrouter,
154174
"openai": _probe_openai,
175+
"novita": _probe_novita,
155176
"ollama": _probe_ollama,
156177
"mock": _probe_mock,
157178
}

grapharc/gateway/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
get_model("claude-cli/claude-sonnet-5") # subscription, no API key
77
get_model("openrouter/anthropic/claude-sonnet-4.5") # many providers, one key
88
get_model("openai/gpt-4o-mini") # OPENAI_API_KEY
9+
get_model("novita/moonshotai/kimi-k3") # NOVITA_API_KEY
910
get_model("ollama/llama3.1") # local server, no key
1011
get_model("mock/x", responses=[...]) # deterministic tests
1112
@@ -16,12 +17,13 @@
1617
get_model(spec, cost_ceiling_usd=0.25) # raises when passed
1718
get_model(spec, spend=shared_meter) # one ceiling, many models
1819
19-
The three OpenAI-wire backends (`openrouter`, `openai`, `ollama`) are imported
20-
lazily — they need `langchain-openai`, which is an optional extra.
20+
The four OpenAI-wire backends (`openrouter`, `openai`, `novita`, `ollama`) are
21+
imported lazily — they need `langchain-openai`, which is an optional extra.
2122
"""
2223

2324
from grapharc.gateway.claude_cli import ClaudeCodeCLIChatModel
2425
from grapharc.gateway.config import (
26+
novita_api_key,
2527
ollama_api_key,
2628
ollama_base_url,
2729
openai_api_key,
@@ -66,6 +68,7 @@
6668
"different_providers",
6769
"get_model",
6870
"is_transient",
71+
"novita_api_key",
6972
"ollama_api_key",
7073
"ollama_base_url",
7174
"openai_api_key",
@@ -85,6 +88,8 @@
8588
"OpenRouterError": "openrouter",
8689
"OpenAIChatModel": "openai",
8790
"OpenAIError": "openai",
91+
"NovitaChatModel": "novita",
92+
"NovitaError": "novita",
8893
"OllamaChatModel": "ollama",
8994
"OllamaError": "ollama",
9095
}

grapharc/gateway/config.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@
2020
Secrets are returned, never logged. Anything that renders a config for humans
2121
goes through `redact`.
2222
23-
Three key-holding backends, plus one that usually holds none:
23+
Four key-holding backends, plus one that usually holds none:
2424
2525
- **OpenRouter** — `OPENROUTER_API_KEY`.
2626
- **OpenAI** — `OPENAI_API_KEY`, optionally with `OPENAI_BASE_URL` for an
2727
Azure-style or proxied endpoint. `langchain-openai` reads `OPENAI_API_KEY`
2828
from the process environment on its own; going through here as well is what
2929
adds `.env` support, the alternate spellings, and a failure that names the
3030
variable instead of surfacing an SDK error.
31+
- **Novita** — `NOVITA_API_KEY`. The endpoint is fixed
32+
(`grapharc.gateway.novita.NOVITA_BASE_URL`), so unlike OpenAI there is no
33+
base-url override to resolve here.
3134
- **Ollama** — no credential by default: it is a server on your own machine.
3235
What it needs is an address, so `ollama_base_url()` always returns one
3336
(`OLLAMA_HOST` / `OLLAMA_BASE_URL`, else localhost). `OLLAMA_API_KEY` exists
@@ -56,6 +59,13 @@
5659
"openai_api_key",
5760
)
5861

62+
NOVITA_KEYS = (
63+
"NOVITA_API_KEY",
64+
"NOVITA_KEY",
65+
"novita-api-key",
66+
"novita_api_key",
67+
)
68+
5969
# `OPENAI_API_BASE` is the older spelling and is still what a lot of tooling
6070
# sets; both are accepted, the current one first.
6171
OPENAI_BASE_URL_KEYS = (
@@ -133,6 +143,10 @@ def openai_api_key(*, env_file: Path | None = None) -> str | None:
133143
return get_secret(OPENAI_KEYS, env_file=env_file)
134144

135145

146+
def novita_api_key(*, env_file: Path | None = None) -> str | None:
147+
return get_secret(NOVITA_KEYS, env_file=env_file)
148+
149+
136150
def openai_base_url(*, env_file: Path | None = None) -> str | None:
137151
"""An endpoint override, or None for api.openai.com.
138152

grapharc/gateway/novita.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Novita backend — a GPU cloud hosting open-weight models, one key.
2+
3+
`novita/moonshotai/kimi-k3` reaches Novita's own OpenAI-compatible endpoint
4+
(`https://api.novita.ai/openai`), not api.openai.com, so this builds on
5+
`OpenAICompatChatModel` the same way `openrouter.py` and `ollama.py` do rather
6+
than on `openai.py`: the endpoint is fixed, not an override of OpenAI's own.
7+
8+
Model ids on Novita are themselves `author/slug` — `moonshotai/kimi-k3`,
9+
`zai-org/glm-5.2` — the same shape OpenRouter uses, so `vendor()` in
10+
`registry.py` already reads the right author off a Novita spec with no
11+
backend-specific handling: `BACKEND_VENDOR` stays absent for `novita`, exactly
12+
as it is absent for `openrouter`.
13+
14+
**Novita reports no per-call cost.** Unlike OpenRouter, the chat-completions
15+
response carries token counts and nothing else, so this backend is the OpenAI
16+
one in that respect: `_provider_cost` is the base class's `None`, and
17+
`cost_ceiling_usd` counts calls in `SpendMeter.unpriced_calls` unless a caller
18+
supplies `price_per_million=`.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
from typing import Any
24+
25+
from grapharc.gateway.config import novita_api_key
26+
from grapharc.gateway.openai_compat import OpenAICompatChatModel
27+
28+
NOVITA_BASE_URL = "https://api.novita.ai/openai"
29+
30+
31+
class NovitaError(Exception):
32+
"""The Novita backend could not be constructed or used."""
33+
34+
35+
class NovitaChatModel(OpenAICompatChatModel):
36+
"""A LangChain chat model over Novita's OpenAI-compatible endpoint."""
37+
38+
def __init__(self, model: str, /, **kwargs: Any) -> None:
39+
api_key = kwargs.pop("api_key", None) or novita_api_key()
40+
if not api_key:
41+
raise NovitaError(
42+
"No Novita API key found. Set NOVITA_API_KEY in the environment, "
43+
"or add one of NOVITA_API_KEY / novita-api-key to a .env file."
44+
)
45+
# One retry layer, not two — same reasoning as the OpenRouter backend.
46+
kwargs.setdefault("max_retries", 0)
47+
super().__init__(
48+
model=model,
49+
api_key=api_key,
50+
base_url=kwargs.pop("base_url", None) or NOVITA_BASE_URL,
51+
**kwargs,
52+
)
53+
54+
@property
55+
def _llm_type(self) -> str:
56+
return "grapharc-novita"
57+
58+
59+
__all__ = ["NOVITA_BASE_URL", "NovitaChatModel", "NovitaError"]

grapharc/gateway/registry.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
openrouter/anthropic/claude-sonnet-4.5 -> OpenRouter
88
openrouter/openai/gpt-4o:floor -> OpenRouter, cheapest provider
99
openai/gpt-4o-mini -> OpenAI directly (OPENAI_API_KEY)
10+
novita/moonshotai/kimi-k3 -> Novita (NOVITA_API_KEY)
1011
ollama/llama3.1 -> a local Ollama server, no key
1112
mock/anything -> scripted test double
1213
@@ -28,7 +29,7 @@ class UnknownBackendError(Exception):
2829
"""The spec named a backend that is not registered."""
2930

3031

31-
BACKENDS = ("claude-cli", "openrouter", "openai", "ollama", "mock")
32+
BACKENDS = ("claude-cli", "openrouter", "openai", "novita", "ollama", "mock")
3233

3334
# Authors that appear in OpenRouter model ids. A spec starting with one of
3435
# these is a model name, not a mistyped backend — `anthropic/claude-haiku-4.5`
@@ -71,6 +72,7 @@ class UnknownBackendError(Exception):
7172
_BARE_BACKEND_EXAMPLE = {
7273
"openrouter": "openrouter/anthropic/claude-sonnet-4.5",
7374
"openai": "openai/gpt-4o-mini",
75+
"novita": "novita/moonshotai/kimi-k3",
7476
"ollama": "ollama/llama3.1",
7577
}
7678

@@ -160,6 +162,17 @@ def get_model(spec: str, **kwargs: Any) -> BaseChatModel:
160162

161163
return OpenAIChatModel(model, **kwargs)
162164

165+
if backend == "novita":
166+
try:
167+
from grapharc.gateway.novita import NovitaChatModel
168+
except ImportError as exc: # pragma: no cover - depends on install extras
169+
raise UnknownBackendError(
170+
"The Novita backend needs langchain-openai. "
171+
"Install it with: uv sync --extra novita"
172+
) from exc
173+
174+
return NovitaChatModel(model, **kwargs)
175+
163176
if backend == "ollama":
164177
try:
165178
from grapharc.gateway.ollama import OllamaChatModel

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ openrouter = [
7171
openai = [
7272
"langchain-openai>=0.2",
7373
]
74+
# Imported by grapharc/gateway/novita.py. Novita speaks the OpenAI wire format
75+
# against its own endpoint, so this needs the same client and no Novita-specific
76+
# package.
77+
novita = [
78+
"langchain-openai>=0.2",
79+
]
7480
# Imported by grapharc/gateway/ollama.py. Ollama speaks the OpenAI wire format,
7581
# so the local backend needs the same client and no ollama-specific package.
7682
ollama = [
@@ -115,7 +121,7 @@ slack = [
115121
]
116122
# Everything above. Self-referential so it cannot drift out of sync.
117123
all = [
118-
"grapharc[api,ladybug,mcp,ollama,openai,openrouter,otel,server,slack]",
124+
"grapharc[api,ladybug,mcp,novita,ollama,openai,openrouter,otel,server,slack]",
119125
]
120126

121127
[dependency-groups]

tests/test_cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ def test_models_check_exits_one_when_nothing_is_configured(monkeypatch, capsys):
438438
for name in (
439439
"OPENROUTER_API_KEY", "OPENROUTER_KEY", "open-router-api-key",
440440
"OPENAI_API_KEY", "OPENAI_KEY", "openai-api-key",
441+
"NOVITA_API_KEY", "NOVITA_KEY", "novita-api-key",
441442
"OLLAMA_HOST", "OLLAMA_BASE_URL",
442443
):
443444
monkeypatch.delenv(name, raising=False)
@@ -452,10 +453,11 @@ def test_models_check_exits_one_when_nothing_is_configured(monkeypatch, capsys):
452453
"claude-cli": False,
453454
"openrouter": False,
454455
"openai": False,
456+
"novita": False,
455457
"ollama": False,
456458
"mock": True,
457459
}
458-
for backend in ("openrouter", "openai"):
460+
for backend in ("openrouter", "openai", "novita"):
459461
assert next(b for b in payload["backends"] if b["backend"] == backend)[
460462
"credential"
461463
] == "<unset>"

0 commit comments

Comments
 (0)