diff --git a/docs/cookbook/02-models.md b/docs/cookbook/02-models.md index 1c90f75..573f64f 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/moonshotai/kimi-k3", "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/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 ``` -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/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 @@ -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..e5cc9d3 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/moonshotai/kimi-k3": "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..47a9c14 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/moonshotai/kimi-k3") # 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..1874f0c --- /dev/null +++ b/grapharc/gateway/novita.py @@ -0,0 +1,59 @@ +"""Novita backend — a GPU cloud hosting open-weight models, one key. + +`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` — `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`. + +**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..3338669 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/moonshotai/kimi-k3 -> 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/moonshotai/kimi-k3", "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..784eae7 --- /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("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/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("moonshotai/kimi-k3") + 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/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): + 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/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/moonshotai/kimi-k3") + + +def test_an_unknown_backend_still_names_novita(): + with pytest.raises(UnknownBackendError, match="novita"): + 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/moonshotai/kimi-k3") == "moonshotai" + assert vendor("novita/zai-org/glm-5.2") == "zai-org" + + +# ------------------------------------------------- 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("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("moonshotai/kimi-k3") + 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("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 + 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( + "moonshotai/kimi-k3", + 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("moonshotai/kimi-k3").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/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.")] + ) + 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 = [