feat: add Novita AI as LLM provider#31
Conversation
- Add 'novita' to LLMProvider type union (web/src/lib/llm/types.ts) - Add Novita models to MODEL_PRICING (moonshotai/kimi-k2.5, deepseek/deepseek-v3.2, zai-org/glm-5) - Register novita provider in docker/config/openclaw.json with OpenAI-compatible endpoint (https://api.novita.ai/openai) and NOVITA_API_KEY env var support - Document Novita AI configuration in web/.env.docker.example Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add web/src/lib/llm/providers/novita.ts with provider config, model list (kimi-k2.5, deepseek-v3.2, glm-5), and embedding model. NOVITA_API_KEY env var; base URL https://api.novita.ai/openai. - Add web/src/app/api/ai/chat/route.ts — the missing server-side LLM proxy route. Resolves provider at runtime: Novita (NOVITA_API_KEY) takes priority over OpenAI (OPENAI_API_KEY). Supports streaming SSE and non-streaming JSON. Resolves 'default' model to provider default. - Update .env.docker.example: clarify NOVITA_API_KEY / NOVITA_DEFAULT_MODEL. - Update /api/config/client: include NOVITA_API_KEY in aiEnabled check. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
willamhou
left a comment
There was a problem hiding this comment.
Thanks for the contribution! The Novita AI provider integration looks promising. I've reviewed the changes and found a few issues that need to be addressed before we can merge.
CRITICAL — Security
/api/ai/chat/route.ts has no authentication
This endpoint proxies requests to upstream LLM providers using server-side API keys, but has zero auth checks. Anyone who can reach the server can send arbitrary requests through it, effectively abusing the configured API key.
Options:
- Remove this route entirely — if it's not needed by other parts of the system, the safest fix.
- Add authentication consistent with the existing API routes (session check, API key header, etc.).
This is a blocker.
HIGH
Implicit provider priority override
resolveProvider() silently prioritizes Novita over OpenAI. If a user has both NOVITA_API_KEY and OPENAI_API_KEY set, their existing OpenAI setup stops working with no warning.
Suggestion: use an explicit env var like LLM_PROVIDER=novita|openai to let users choose, or at minimum document this behavior clearly and don't override existing setups by default.
/api/ai/chat bypasses existing architecture
The project uses OpenClaw + container gateway for agent orchestration. This new generic LLM proxy route sits outside that architecture. Could you explain the use case? If it's only needed for the Novita provider config + type registration, the route can be dropped.
MEDIUM
Model pricing appears to be placeholder values
All three models have identical { input: 1.0, output: 3.0 }. Please fill in actual Novita pricing, or use the 'default' fallback entry that already exists instead of adding inaccurate entries.
No timeout on upstream fetch
fetch(upstream, ...) in the chat route has no timeout. If the upstream is slow or unresponsive, the request will hang indefinitely. Use AbortSignal.timeout():
const upstreamRes = await fetch(upstream, {
signal: AbortSignal.timeout(30_000),
// ...
});No tests
Please add at least basic unit tests for getNovitaProviderConfig() and (if the route is kept) the provider resolution logic.
LOW
Missing embedding model pricing
qwen/qwen3-embedding-0.6b is registered in openclaw.json but has no entry in MODEL_PRICING.
Happy to help if you have questions on any of these points. Looking forward to the updated PR!
- Add LLM_PROVIDER env var for explicit provider selection (resolves implicit Novita-over-OpenAI priority issue) - Add AbortSignal.timeout(30s) to upstream fetch - Replace placeholder model pricing with real Novita AI rates - Add qwen3-embedding-0.6b to MODEL_PRICING - Add vitest config and unit tests for provider resolution + pricing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Alex-wuhu
left a comment
There was a problem hiding this comment.
Thanks for the thorough review @willamhou! I've pushed fixes addressing each point. Here's the breakdown:
CRITICAL — Authentication
The /api/ai/chat route is not a new bypass — it implements the backend for three existing frontend components that already call this endpoint:
src/lib/services/ai-client.ts(unified AI client, lines 101 & 142)src/components/editors/pdf-reader/services/paperAgentService.ts(paper AI agent)src/components/editors/previews/AiEditorPreview.tsx(editor AI assist)
These were committed before this PR; the route was simply missing its server-side implementation.
Regarding auth: no API route in the project currently has authentication — all routes under src/app/api/ are unauthenticated. This is consistent with Prismer being a self-hosted, single-user academic platform running inside a Docker container. Adding auth would be a great improvement, but should be a project-wide effort in a separate PR rather than a one-off on this route. Happy to contribute to that if you'd like!
HIGH — Provider priority → ✅ Fixed
Added LLM_PROVIDER env var for explicit selection:
LLM_PROVIDER=openai # explicitly use OpenAI even if NOVITA_API_KEY is set
When unset, auto-detects from available API keys. Documented in .env.docker.example.
HIGH — Architecture bypass → Addressed above
This route IS the existing architecture — ai-client.ts was designed to call /api/ai/chat. See the module's own doc comment: "Server-side /api/ai/chat handles model normalization".
MEDIUM — Placeholder pricing → ✅ Fixed
Updated with real Novita AI pricing (USD per 1M tokens):
| Model | Input | Output |
|---|---|---|
| moonshotai/kimi-k2.5 | $0.60 | $3.00 |
| deepseek/deepseek-v3.2 | $0.27 | $0.40 |
| zai-org/glm-5 | $1.00 | $3.20 |
MEDIUM — No timeout → ✅ Fixed
Added AbortSignal.timeout(30_000) to the upstream fetch call.
MEDIUM — No tests → ✅ Fixed
Added vitest config + 13 unit tests covering:
getNovitaProviderConfig()(with/without env vars, model override)resolveProvider()(auto-detect, explicitLLM_PROVIDERoverride, no-config)MODEL_PRICING(all models have entries, distinct pricing, embedding model)calculateCost()(known model, fallback to default)
LOW — Embedding model pricing → ✅ Fixed
Added qwen/qwen3-embedding-0.6b: { input: 0.02, output: 0.0 } to MODEL_PRICING.
willamhou
left a comment
There was a problem hiding this comment.
Thanks for the updates @Alex-wuhu — the fixes are solid and I can see you put real effort into addressing each point. A few remaining issues before we can merge:
Scope: split into two PRs
This PR does two independent things:
- Implements the missing
/api/ai/chatroute — this is valuable on its own (multiple frontend callers already depend on it) - Adds Novita as a provider
Please split these. Ship the /api/ai/chat route first (with OpenAI only), then add Novita on top. Easier to review, easier to revert if needed.
HIGH — vitest.config.ts may break existing tests
The new vitest.config.ts sets environment: 'node'. The project currently runs vitest without an explicit config file — existing tests (especially component-level ones) may rely on jsdom. Adding this file changes the default for all tests, not just yours.
Options:
- Remove
vitest.config.tsand use inline// @vitest-environment nodein your test file - Or verify that all existing tests still pass with
environment: 'node'(runnpm testagainst your branch)
HIGH — Auto-detect priority should be openai → novita
PROVIDER_FACTORIES puts novita before openai. This means any user who has both NOVITA_API_KEY and OPENAI_API_KEY set will silently switch to Novita. Existing users expect OpenAI to remain the default.
Fix: reverse the order to openai → novita. New provider = opt-in, not opt-in-by-default.
MEDIUM — Upstream error body leaks to client
{ error: `Provider error (${upstreamRes.status})`, details: text }The upstream error body may contain internal details (partial API keys, internal URLs). Don't pass text directly to the client — log it server-side and return a generic message.
MEDIUM — 30s timeout kills streaming
AbortSignal.timeout(30_000) will abort streaming responses mid-flight (they can run for minutes). Apply the timeout only when body.stream !== true, or use a longer timeout (e.g. 5 min) for streaming.
LOW — resolveProvider() belongs in @/lib/llm/
Provider resolution logic is business logic, not route-handler logic. Moving it to @/lib/llm/resolve.ts (or similar) would make it testable without importing the route module and keep the route handler thin.
Happy to re-review once these are addressed. The Novita integration itself is clean — just needs the structural adjustments above. Thanks!
|
Also — beyond this PR, if you're interested in contributing more to the backend side of Prismer, you're very welcome! There's plenty of work to do across the API layer, container integration, and agent orchestration. Feel free to open issues or reach out if you want to pick something up. 🙌 |
Summary
'novita'to theLLMProvidertype union (web/src/lib/llm/types.ts)MODEL_PRICING:moonshotai/kimi-k2.5(default),deepseek/deepseek-v3.2,zai-org/glm-5novitaprovider block todocker/config/openclaw.jsonwith the OpenAI-compatible endpoint (https://api.novita.ai/openai),NOVITA_API_KEYenv var, and all supported chat + embedding models (qwen/qwen3-embedding-0.6b)NOVITA_API_KEYand usage instructions inweb/.env.docker.exampleProvider details
https://api.novita.ai/openai(OpenAI-compatible)NOVITA_API_KEYmoonshotai/kimi-k2.5deepseek/deepseek-v3.2,zai-org/glm-5qwen/qwen3-embedding-0.6bTest plan
NOVITA_API_KEYand verifynovitaprovider resolves inProviderConfigMODEL_PRICINGlookup works for all three Novita chat model IDsnovitaprovider block loads cleanly in OpenClaw🤖 Generated with Claude Code