Files
OpenJarvis/src/openjarvis/server/routes.py
T
kelliott-cloudandElliot Slusky 2ed885eb11 fix: never auto-select embed-only models for chat (#659)
* fix: never auto-select embed-only models for chat

Ollama lists nomic-embed-text alongside chat models. Auto-picking
models[0] / recommending the only available id selected the embedder
and every generation failed with HTTP 400 "does not support chat".

- Filter embed-only ids out of GET /v1/models (chat picker)
- Exclude them from /v1/recommended-model; return empty when none left
- Frontend setModels prefers chat models and clears a bad embed selection
- Regression tests for mixed, embed-only, and classifier cases

* fix: harden chat model capability filtering

---------

Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-08-10 17:06:48 -07:00

1240 lines
44 KiB
Python

"""Route handlers for the OpenAI-compatible API server."""
from __future__ import annotations
import asyncio
import logging
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from openjarvis.core.paths import get_config_dir
from openjarvis.core.types import Message, Role
from openjarvis.server.model_capabilities import is_embed_only_model
from openjarvis.server.models import (
ChatCompletionChunk,
ChatCompletionRequest,
ChatCompletionResponse,
Choice,
ChoiceMessage,
ComplexityInfo,
DeltaMessage,
ModelListResponse,
ModelObject,
StreamChoice,
UsageInfo,
)
router = APIRouter()
def _to_messages(chat_messages) -> list[Message]:
"""Convert Pydantic ChatMessage objects to core Message objects."""
messages = []
for m in chat_messages:
role = Role(m.role) if m.role in {r.value for r in Role} else Role.USER
messages.append(
Message(
role=role,
content=m.content or "",
name=m.name,
tool_call_id=m.tool_call_id,
)
)
return messages
def _ensure_identity_prompt(messages: list[Message], app_config) -> list[Message]:
"""Prepend OpenJarvis's identity system prompt when the client omits one.
The desktop UI's chat backend posts only user/assistant turns to
``/v1/chat/completions`` (see ``frontend/.../Chat/InputArea.tsx``), so
nothing grounds the model's identity. Without a system prompt the model
answers from its training identity (e.g. "I'm Claude", "I am Qwen"),
which is what #540 reported. The CLI paths inject this via
``SystemPromptBuilder`` / ``BaseAgent``; the engine-direct server paths
did not. This mirrors the agent fallback in ``agents/_stubs.py``.
If any message already carries a system role, the caller has supplied
their own grounding and we leave the list untouched (no double-prompting).
Resolution of the identity text: the config comes from ``app.state`` when
wired, otherwise ``load_config()``; the prompt itself is assembled by
``SystemPromptBuilder`` from ``agent.default_system_prompt`` plus the
persona files (SOUL.md/MEMORY.md/USER.md), matching
``_build_managed_system_prompt`` in ``agent_manager_routes.py``. Config
resolution is wrapped so a broken/missing config degrades to "no
injection" rather than crashing the endpoint, but the failure is logged
(per REVIEW.md — never silently swallow).
"""
if any(m.role == Role.SYSTEM for m in messages):
return messages
prompt = ""
try:
cfg = app_config
if cfg is None:
from openjarvis.core.config import load_config
cfg = load_config()
from openjarvis.prompt.builder import SystemPromptBuilder
builder = SystemPromptBuilder(
agent_template=cfg.agent.default_system_prompt or "",
memory_files_config=getattr(cfg, "memory_files", None),
system_prompt_config=getattr(cfg, "system_prompt", None),
)
prompt = builder.build()
except Exception:
logging.getLogger("openjarvis.server").debug(
"Identity system prompt resolution failed; "
"serving request without identity grounding",
exc_info=True,
)
return messages
if not prompt:
return messages
return [Message(role=Role.SYSTEM, content=prompt), *messages]
@router.post("/v1/chat/completions")
async def chat_completions(request_body: ChatCompletionRequest, request: Request):
"""Handle chat completion requests (streaming and non-streaming)."""
engine = request.app.state.engine
agent = getattr(request.app.state, "agent", None)
model = request_body.model
# Inject memory context into messages before dispatching
config = getattr(request.app.state, "config", None)
memory_backend = getattr(request.app.state, "memory_backend", None)
if (
config is not None
and memory_backend is not None
and config.agent.context_from_memory
and request_body.messages
):
try:
from openjarvis.tools.storage.context import ContextConfig, inject_context
# Extract query from the last user message
query_text = ""
for m in reversed(request_body.messages):
if m.role == "user" and m.content:
query_text = m.content
break
if query_text:
messages = _to_messages(request_body.messages)
ctx_cfg = ContextConfig(
top_k=config.memory.context_top_k,
min_score=config.memory.context_min_score,
max_context_tokens=config.memory.context_max_tokens,
)
enriched = inject_context(
query_text,
messages,
memory_backend,
config=ctx_cfg,
)
# Rebuild request messages from enriched Message objects
if len(enriched) > len(messages):
from openjarvis.server.models import ChatMessage
new_msgs = []
for msg in enriched:
new_msgs.append(
ChatMessage(
role=msg.role.value,
content=msg.content,
name=msg.name,
tool_call_id=getattr(msg, "tool_call_id", None),
)
)
request_body.messages = new_msgs
except Exception:
logging.getLogger("openjarvis.server").debug(
"Memory context injection failed",
exc_info=True,
)
# Run complexity analysis on the last user message
complexity_info = None
query_text_for_complexity = ""
for m in reversed(request_body.messages):
if m.role == "user" and m.content:
query_text_for_complexity = m.content
break
if query_text_for_complexity:
try:
from openjarvis.learning.routing.complexity import (
adjust_tokens_for_model,
score_complexity,
)
cr = score_complexity(query_text_for_complexity)
suggested = adjust_tokens_for_model(
cr.suggested_max_tokens,
model,
)
complexity_info = ComplexityInfo(
score=cr.score,
tier=cr.tier,
suggested_max_tokens=suggested,
)
# Bump max_tokens when complexity suggests more than what
# the client requested — never reduce below the request value.
if suggested > request_body.max_tokens:
request_body.max_tokens = suggested
except Exception:
logging.getLogger("openjarvis.server").debug(
"Complexity analysis failed",
exc_info=True,
)
if request_body.stream:
# When the client passes `tools`, stream the model's raw
# OpenAI-compat function-calling decision directly from the engine
# (bypassing the agent) — the streaming mirror of the non-streaming
# #454 fix. Routing tools through the agent stream bridge ignored
# `request_body.tools`, ran the agent's own tool loop, and
# word-split generic filler content into fake token deltas, so the
# caller's tool_calls were dropped entirely (the streaming analog of
# #414). For plain chat (no tools), stream token-by-token directly
# from the engine for true real-time output.
if request_body.tools:
return await _handle_stream_tools(
engine,
model,
request_body,
complexity_info,
app_config=config,
bus=getattr(request.app.state, "bus", None),
memory_service=getattr(request.app.state, "memory_service", None),
)
return await _handle_stream(
engine,
model,
request_body,
complexity_info,
trace_store=getattr(request.app.state, "trace_store", None),
app_config=config,
bus=getattr(request.app.state, "bus", None),
memory_service=getattr(request.app.state, "memory_service", None),
)
# Non-streaming: use agent if available, otherwise direct engine call.
#
# EXCEPTION: when the client explicitly passed `tools`, they're asking
# for raw OpenAI-compat function-calling — return the model's
# tool_call decision verbatim. Routing through `_handle_agent` would
# call `agent.run(input_text)`, which IGNORES `request_body.tools`,
# runs the agent's own internal tool loop with its own (different)
# tool spec, and returns only `result.content` — so the model's
# tool_calls vanish and the user sees a generic acknowledgement
# (e.g. "Understood. If you have another request...") that the
# agent's re-prompted LLM produced. See #414.
#
# If a future caller needs agent orchestration WITH client-supplied
# tools (e.g. injecting MCP tools through this endpoint and wanting
# the agent to execute them), add an explicit opt-in header rather
# than removing this guard — silent re-routing is what produced #414.
# ``_handle_agent`` (sync ``agent.run()``) and ``_handle_direct`` (sync
# ``engine.generate()``) both make blocking upstream calls; run them in a
# worker thread so a slow/wedged non-streaming request can't stall the
# event loop and every other concurrent request with it.
if agent is not None and not request_body.tools:
response = await asyncio.to_thread(
_handle_agent,
agent,
model,
request_body,
complexity_info,
trace_store=getattr(request.app.state, "trace_store", None),
bus=getattr(request.app.state, "bus", None),
)
else:
bus = getattr(request.app.state, "bus", None)
response = await asyncio.to_thread(
_handle_direct,
engine,
model,
request_body,
bus=bus,
complexity_info=complexity_info,
app_config=config,
)
# Hand the completed exchange to the background memory service.
_remember_exchange(
getattr(request.app.state, "memory_service", None),
query_text_for_complexity,
response,
bus=getattr(request.app.state, "bus", None),
source="server.chat",
)
return response
def _response_content(response) -> str:
"""Extract assistant text from an OpenAI-compatible response object."""
content = ""
choices = getattr(response, "choices", None)
if choices:
content = getattr(choices[0].message, "content", "") or ""
return content
def _record_completed_exchange(
memory_service,
user_text: str,
assistant_text: str,
*,
bus=None,
source: str = "server.chat",
) -> None:
"""Publish or submit a completed exchange without blocking a reply."""
if not user_text:
return
try:
if bus is not None:
from openjarvis.memory import publish_completed_exchange
publish_completed_exchange(
bus,
user_text,
assistant_text,
source=source,
)
elif memory_service is not None:
memory_service.submit(user_text, assistant_text)
except Exception: # noqa: BLE001 — memory is best-effort, never fail a reply
logging.getLogger("openjarvis.server").debug(
"Memory submit failed",
exc_info=True,
)
def _remember_exchange(
memory_service,
user_text: str,
response,
*,
bus=None,
source: str = "server.chat",
) -> None:
"""Record a completed non-streaming exchange."""
_record_completed_exchange(
memory_service,
user_text,
_response_content(response),
bus=bus,
source=source,
)
def _engine_key_for_model(engine: Any, model: str) -> str | None:
"""Resolve the engine that advertised *model* through wrapper layers."""
from openjarvis.engine.multi import MultiEngine
from openjarvis.security.guardrails import GuardrailsEngine
from openjarvis.telemetry.instrumented_engine import InstrumentedEngine
current = engine
while current is not None:
if isinstance(current, MultiEngine):
return current.engine_key_for(model)
if isinstance(current, InstrumentedEngine):
current = current._inner
continue
if isinstance(current, GuardrailsEngine):
current = current._engine
continue
engine_id = getattr(current, "engine_id", None)
return engine_id if isinstance(engine_id, str) else None
return None
def _uses_direct_cloud_router(engine: Any, model: str) -> bool:
"""Whether *model* should bypass the configured engine for direct cloud."""
from openjarvis.server.cloud_router import is_cloud_model
return is_cloud_model(model) and _engine_key_for_model(engine, model) != "litellm"
def _handle_direct(
engine,
model: str,
req: ChatCompletionRequest,
bus=None,
complexity_info=None,
app_config=None,
) -> ChatCompletionResponse:
"""Direct engine call without agent."""
messages = _to_messages(req.messages)
messages = _ensure_identity_prompt(messages, app_config)
kwargs: dict[str, Any] = {}
if req.tools:
kwargs["tools"] = req.tools
if bus:
from openjarvis.telemetry.instrumented_engine import InstrumentedEngine
from openjarvis.telemetry.wrapper import instrumented_generate
# `app.state.engine` may already be an InstrumentedEngine (the
# common case when telemetry is wired in). If we then wrap it
# with `instrumented_generate`, BOTH layers fire a
# TELEMETRY_RECORD per call:
#
# - InstrumentedEngine.generate() publishes a FULL record
# (energy_joules, GPU stats, token_counting_version, ...).
# - instrumented_generate() publishes a BARE record (timing +
# tokens only; no energy meter, no version stamp).
#
# The doubled count was the dominant driver of the bimodal
# Wh/token distribution on the public leaderboard.
#
# The fix below is NOT "unwrap and call instrumented_generate":
# that would have replaced "doubled records" with "every
# request emits only a bare record with no energy / no version",
# which the leaderboard's `current_methodology_only=True` filter
# would then drop entirely. Instead, when the engine is already
# an InstrumentedEngine, skip the wrapper and call `generate`
# directly — InstrumentedEngine publishes the full per-record
# event itself with energy + version intact. Only fall back to
# the lightweight wrapper for engines that aren't already
# instrumented.
if isinstance(engine, InstrumentedEngine):
result = engine.generate(
messages,
model=model,
temperature=req.temperature,
max_tokens=req.max_tokens,
**kwargs,
)
else:
result = instrumented_generate(
engine,
messages,
model=model,
bus=bus,
temperature=req.temperature,
max_tokens=req.max_tokens,
**kwargs,
)
else:
result = engine.generate(
messages,
model=model,
temperature=req.temperature,
max_tokens=req.max_tokens,
**kwargs,
)
content = result.get("content", "")
usage = result.get("usage", {})
choice_msg = ChoiceMessage(role="assistant", content=content)
# Include tool calls if present
tool_calls = result.get("tool_calls")
if tool_calls:
choice_msg.tool_calls = [
{
"id": tc.get("id", ""),
"type": "function",
"function": {
"name": tc.get("name", ""),
"arguments": tc.get("arguments", "{}"),
},
}
for tc in tool_calls
]
return ChatCompletionResponse(
model=model,
choices=[
Choice(
message=choice_msg,
finish_reason=result.get("finish_reason", "stop"),
)
],
usage=UsageInfo(
prompt_tokens=usage.get("prompt_tokens", 0),
completion_tokens=usage.get("completion_tokens", 0),
total_tokens=usage.get("total_tokens", 0),
),
complexity=complexity_info,
)
def _handle_agent(
agent,
model: str,
req: ChatCompletionRequest,
complexity_info=None,
*,
trace_store=None,
bus=None,
) -> ChatCompletionResponse:
"""Run through agent.
When *trace_store* is set, the agent run is wrapped in a
``TraceCollector`` (mirroring ``system/orchestrator.py``) so every
completion records a ``Trace`` to ``traces.db``. Previously this endpoint
called ``agent.run()`` raw, so the server never produced traces:
``traces.db`` stayed empty and spec_search's cold-start gate
(``check_readiness``, min 20 traces) could never open.
"""
from openjarvis.agents._stubs import AgentContext
# Build context from prior messages
ctx = AgentContext()
if len(req.messages) > 1:
prior = _to_messages(req.messages[:-1])
for m in prior:
ctx.conversation.add(m)
# Last message is the input
input_text = req.messages[-1].content if req.messages else ""
# Override agent model for this request if the caller specified one
original_model = agent._model
if model:
agent._model = model
try:
if trace_store is not None:
from openjarvis.traces.collector import TraceCollector
collector = TraceCollector(agent, store=trace_store, bus=bus)
result = collector.run(input_text, context=ctx)
else:
result = agent.run(input_text, context=ctx)
finally:
agent._model = original_model
usage = UsageInfo(
prompt_tokens=result.metadata.get("prompt_tokens", 0),
completion_tokens=result.metadata.get("completion_tokens", 0),
total_tokens=result.metadata.get("total_tokens", 0),
)
# Include audio metadata if the agent produced audio (e.g. morning digest)
audio_meta = None
audio_path = result.metadata.get("audio_path", "")
if audio_path:
from pathlib import Path
from openjarvis.server.models import AudioMeta
if Path(audio_path).exists():
audio_meta = AudioMeta(url="/api/digest/audio")
return ChatCompletionResponse(
model=model,
choices=[
Choice(
message=ChoiceMessage(
role="assistant",
content=result.content,
audio=audio_meta,
),
finish_reason="stop",
)
],
usage=usage,
complexity=complexity_info,
)
async def _handle_stream_tools(
engine,
model: str,
req: ChatCompletionRequest,
complexity_info=None,
*,
app_config=None,
bus=None,
memory_service=None,
):
"""Stream a raw OpenAI-compat function-calling response via SSE.
Used when the client passes `tools` together with `stream:true`. Sources
tool_calls from ``engine.stream_full()`` (which forwards the tools to the
backend and parses tool_calls out of the streamed response) and emits them
as SSE deltas, bypassing the agent entirely. This is the streaming mirror
of the non-streaming ``_handle_direct`` tool path.
Engines without a tool-aware ``stream_full`` override fall back to the
base-class default (content tokens + a ``stop`` finish_reason, no
tool_calls) — identical to the prior plain-stream behaviour, so this never
regresses non-tool-capable engines.
"""
messages = _to_messages(req.messages)
messages = _ensure_identity_prompt(messages, app_config)
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
use_cloud = _uses_direct_cloud_router(engine, model)
telemetry_engine = (
"cloud" if use_cloud else (_engine_key_for_model(engine, model) or "ollama")
)
query_text = ""
for _m in reversed(req.messages):
if _m.role == "user" and _m.content:
query_text = _m.content
break
async def generate():
full_content = ""
# Send the role chunk first (OpenAI convention).
first_chunk = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[StreamChoice(delta=DeltaMessage(role="assistant"))],
)
yield f"data: {first_chunk.model_dump_json()}\n\n"
finish_reason = "stop"
try:
async for sc in engine.stream_full(
messages,
model=model,
temperature=req.temperature,
max_tokens=req.max_tokens,
tools=req.tools,
):
if sc.content:
full_content += sc.content
content_chunk = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[StreamChoice(delta=DeltaMessage(content=sc.content))],
)
yield f"data: {content_chunk.model_dump_json()}\n\n"
if sc.tool_calls:
tc_chunk = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[
StreamChoice(delta=DeltaMessage(tool_calls=sc.tool_calls))
],
)
yield f"data: {tc_chunk.model_dump_json()}\n\n"
if sc.finish_reason:
finish_reason = sc.finish_reason
except Exception as exc:
import logging
logging.getLogger("openjarvis.server").error(
"Tool stream error: %s",
exc,
exc_info=True,
)
error_chunk = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[
StreamChoice(
delta=DeltaMessage(
content=f"\n\nError during generation: {exc}",
),
finish_reason="stop",
)
],
)
yield f"data: {error_chunk.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
return
import json as _json
finish_data = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[StreamChoice(delta=DeltaMessage(), finish_reason=finish_reason)],
)
finish_dict = _json.loads(finish_data.model_dump_json())
# Tag the finish chunk with the engine label, matching _handle_stream
# so UI/telemetry consumers see the same field on the tools path.
finish_dict.setdefault("telemetry", {})
finish_dict["telemetry"]["engine"] = telemetry_engine
if complexity_info is not None:
finish_dict["complexity"] = complexity_info.model_dump()
yield f"data: {_json.dumps(finish_dict)}\n\n"
if full_content:
_record_completed_exchange(
memory_service,
query_text,
full_content,
bus=bus,
source="server.chat.stream",
)
yield "data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
async def _handle_stream(
engine,
model: str,
req: ChatCompletionRequest,
complexity_info=None,
*,
trace_store=None,
app_config=None,
bus=None,
memory_service=None,
):
"""Stream response using SSE format.
This path streams straight from the engine, bypassing the agent /
``TraceCollector``. When *trace_store* is set we accumulate the streamed
tokens and record a minimal ``Trace`` once the stream completes
successfully — otherwise streamed chats (the desktop GUI's main path)
would never populate ``traces.db``.
"""
import time
from openjarvis.server.cloud_router import stream_cloud, stream_local
messages = _to_messages(req.messages)
messages = _ensure_identity_prompt(messages, app_config)
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
# Last user message — recorded as the trace query.
query_text = ""
for _m in reversed(req.messages):
if _m.role == "user" and _m.content:
query_text = _m.content
break
# Route directly to the right backend — bypasses engine routing entirely
# so broken MultiEngine state can never misdirect requests.
use_cloud = _uses_direct_cloud_router(engine, model)
telemetry_engine = (
"cloud" if use_cloud else (_engine_key_for_model(engine, model) or "ollama")
)
async def generate():
started_at = time.time()
full_content = ""
# Send role chunk first
first_chunk = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[
StreamChoice(
delta=DeltaMessage(role="assistant"),
)
],
)
yield f"data: {first_chunk.model_dump_json()}\n\n"
try:
# Cloud models → direct cloud API (reads keys from disk).
# Local models → engine.stream() first so mock engines work in
# tests. Fall back to stream_local() only when the engine would
# mis-route the request to a cloud backend (MultiEngine routing
# confusion), which is detected by checking the routed engine's
# is_cloud attribute.
if use_cloud:
token_iter = stream_cloud(
model, messages, req.temperature, req.max_tokens
)
else:
# Use engine.stream() by default (preserves mock-engine
# compatibility in tests). Only fall back to stream_local()
# when a real MultiEngine would mis-route the local model to a
# cloud backend — detected via isinstance so mocks are not
# accidentally matched.
_use_local_fallback = False
try:
from openjarvis.engine.multi import MultiEngine
_inner = getattr(engine, "_inner", engine)
if isinstance(_inner, MultiEngine):
_routed = _inner._engine_for(model)
if _routed is not None and getattr(_routed, "is_cloud", False):
_use_local_fallback = True
except Exception:
pass
if _use_local_fallback:
token_iter = stream_local(
model, messages, req.temperature, req.max_tokens
)
else:
token_iter = engine.stream(
messages,
model=model,
temperature=req.temperature,
max_tokens=req.max_tokens,
)
async for token in token_iter:
full_content += token
chunk = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[
StreamChoice(
delta=DeltaMessage(content=token),
)
],
)
yield f"data: {chunk.model_dump_json()}\n\n"
except Exception as exc:
# Surface errors as a content chunk so the frontend can
# display them instead of silently failing.
import logging
logging.getLogger("openjarvis.server").error(
"Stream error: %s",
exc,
exc_info=True,
)
error_chunk = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[
StreamChoice(
delta=DeltaMessage(
content=f"\n\nError during generation: {exc}",
),
finish_reason="stop",
)
],
)
yield f"data: {error_chunk.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
return
# Record a trace for the completed stream (best-effort; never breaks
# the response). Mirrors the agent path so streamed chats also
# populate traces.db.
if trace_store is not None and full_content:
from openjarvis.traces.collector import record_response_trace
record_response_trace(
trace_store,
query=query_text,
result=full_content,
model=model,
engine=telemetry_engine,
started_at=started_at,
ended_at=time.time(),
)
if full_content:
_record_completed_exchange(
memory_service,
query_text,
full_content,
bus=bus,
source="server.chat.stream",
)
# Send finish chunk with usage data if available
import json as _json
finish_data = ChatCompletionChunk(
id=chunk_id,
model=model,
choices=[
StreamChoice(
delta=DeltaMessage(),
finish_reason="stop",
)
],
)
finish_dict = _json.loads(finish_data.model_dump_json())
# Tag the finish chunk with the correct engine label.
# We use the routing decision (use_cloud) directly rather than
# unwrapping the engine chain, which can be in a broken state.
finish_dict.setdefault("telemetry", {})
finish_dict["telemetry"]["engine"] = telemetry_engine
if complexity_info is not None:
finish_dict["complexity"] = complexity_info.model_dump()
yield f"data: {_json.dumps(finish_dict)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
@router.get("/v1/models")
async def list_models(request: Request) -> ModelListResponse:
"""List selectable engine models for the installed-model picker.
Direct cloud models live in the Cloud Models tab. Models advertised by a
configured LiteLLM engine remain here because LiteLLM owns their routing
and may use provider-qualified IDs that resemble OpenRouter IDs.
"""
from openjarvis.server.cloud_router import is_cloud_model, list_local_models
# Prefer engine.list_models() so mock engines work in tests.
# Filter out direct-cloud model IDs that may appear via MultiEngine, but
# retain provider-qualified IDs owned by the configured LiteLLM engine.
# Fall back to direct Ollama query only when the engine returns nothing.
engine = request.app.state.engine
all_ids = await asyncio.to_thread(engine.list_models)
model_ids = [
m
for m in all_ids
if not is_cloud_model(m) or _engine_key_for_model(engine, m) == "litellm"
]
if not model_ids:
model_ids = await list_local_models()
# Keep embed-only models out of the chat model picker. They still work for
# memory/retrieval via the embedder path; putting them in /v1/models made
# the UI auto-select nomic-embed-text and fail every generation with 400.
model_ids = [m for m in model_ids if not is_embed_only_model(m)]
return ModelListResponse(
data=[
ModelObject(
id=mid,
owned_by=(
"litellm"
if _engine_key_for_model(engine, mid) == "litellm"
else "openjarvis"
),
)
for mid in model_ids
],
)
@router.post("/v1/models/pull")
async def pull_model(request: Request):
"""Pull / download a model from the Ollama registry."""
body = await request.json()
model_name = body.get("model", "").strip()
if not model_name:
raise HTTPException(status_code=400, detail="'model' field is required")
engine = request.app.state.engine
engine_name = getattr(request.app.state, "engine_name", "")
# Only Ollama supports pulling
if engine_name != "ollama" and getattr(engine, "engine_id", "") != "ollama":
raise HTTPException(
status_code=501,
detail="Model pulling is only supported with the Ollama engine",
)
import httpx as _httpx
host = getattr(engine, "_host", "http://localhost:11434")
try:
async with _httpx.AsyncClient(base_url=host, timeout=600.0) as client:
resp = await client.post(
"/api/pull",
json={"name": model_name, "stream": False},
)
resp.raise_for_status()
except (_httpx.ConnectError, _httpx.TimeoutException) as exc:
raise HTTPException(status_code=502, detail=f"Ollama unreachable: {exc}")
except _httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Ollama error: {exc.response.text[:300]}",
)
return {"status": "ok", "model": model_name}
@router.delete("/v1/models/{model_name:path}")
async def delete_model(model_name: str, request: Request):
"""Delete a model from Ollama."""
engine = request.app.state.engine
engine_name = getattr(request.app.state, "engine_name", "")
if engine_name != "ollama" and getattr(engine, "engine_id", "") != "ollama":
raise HTTPException(status_code=501, detail="Only supported with Ollama engine")
import httpx as _httpx
host = getattr(engine, "_host", "http://localhost:11434")
try:
async with _httpx.AsyncClient(base_url=host, timeout=30.0) as client:
resp = await client.request(
"DELETE",
"/api/delete",
json={"name": model_name},
)
resp.raise_for_status()
except (_httpx.ConnectError, _httpx.TimeoutException) as exc:
raise HTTPException(status_code=502, detail=f"Ollama unreachable: {exc}")
except _httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code,
detail=f"Ollama error: {exc.response.text[:300]}",
)
return {"status": "deleted", "model": model_name}
@router.post("/v1/cloud/reload")
async def reload_cloud_engine(request: Request):
"""Hot-reload cloud API keys and (re-)initialize the cloud engine.
Called by the desktop app immediately after the user saves a cloud API
key so that cloud models become available without a full app restart.
"""
import os
submitted_keys: dict[str, str] | None = None
try:
body = await request.json()
raw_keys = body.get("keys") if isinstance(body, dict) else None
if isinstance(raw_keys, dict):
submitted_keys = {
str(k): str(v)
for k, v in raw_keys.items()
if str(k).endswith("_API_KEY")
}
except Exception:
submitted_keys = None
if submitted_keys is not None:
for key, value in submitted_keys.items():
if value:
os.environ[key] = value
else:
os.environ.pop(key, None)
else:
# Compatibility fallback for non-desktop/manual configurations.
keys_path = get_config_dir() / "cloud-keys.env"
if keys_path.exists():
for raw_line in keys_path.read_text().splitlines():
line = raw_line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ[k.strip()] = v.strip()
# Try to build a fresh CloudEngine.
try:
from openjarvis.engine.cloud import CloudEngine
from openjarvis.engine.multi import MultiEngine
cloud = CloudEngine()
if not cloud.health():
return {
"status": "no_cloud",
"message": "No cloud models available (check API keys)",
}
except Exception as exc:
return {"status": "error", "message": str(exc)}
# Locate the innermost engine, working through InstrumentedEngine layers.
outer = request.app.state.engine
inner = getattr(outer, "_inner", outer)
if isinstance(inner, MultiEngine):
# Replace or insert the cloud entry in the existing MultiEngine.
new_engines = [(k, e) for k, e in inner._engines if k != "cloud"]
new_engines.append(("cloud", cloud))
inner._engines = new_engines
inner._refresh_map()
else:
# Wrap the existing engine (which may be security-wrapped) with a new
# MultiEngine that includes the cloud engine.
engine_name = getattr(request.app.state, "engine_name", "local")
new_multi = MultiEngine([(engine_name, inner), ("cloud", cloud)])
if hasattr(outer, "_inner"):
outer._inner = new_multi
else:
request.app.state.engine = new_multi
request.app.state.engine_name = "multi"
return {"status": "ok", "message": "Cloud engine reloaded"}
@router.get("/v1/savings")
async def savings(request: Request):
"""Return savings summary compared to cloud providers.
Only includes telemetry from the current server session so that
counters start at zero each time a new model + agent is launched.
"""
from openjarvis.core.config import DEFAULT_CONFIG_DIR
from openjarvis.server.savings import compute_savings, savings_to_dict
from openjarvis.telemetry.aggregator import TelemetryAggregator
db_path = DEFAULT_CONFIG_DIR / "telemetry.db"
if not db_path.exists():
empty = compute_savings(0, 0, 0)
return savings_to_dict(empty)
session_start = getattr(request.app.state, "session_start", None)
agg = TelemetryAggregator(db_path)
try:
# current_methodology_only excludes pre-fix legacy rows from
# the leaderboard's per-token efficiency numerator/denominator
# — see the comment on _time_filter for the bimodal-Wh/token
# background.
summary = agg.summary(since=session_start, current_methodology_only=True)
# Exclude cloud model tokens from savings — only local
# inference counts toward cost savings.
_cloud_prefixes = (
"gpt-",
"o1-",
"o3-",
"o4-",
"claude-",
"gemini-",
"openrouter/",
)
local_models = [
m
for m in summary.per_model
if not any(m.model_id.startswith(p) for p in _cloud_prefixes)
]
result = compute_savings(
prompt_tokens=sum(m.prompt_tokens for m in local_models),
completion_tokens=sum(m.completion_tokens for m in local_models),
total_calls=sum(m.call_count for m in local_models),
session_start=session_start if session_start else 0.0,
prompt_tokens_evaluated=sum(
m.prompt_tokens_evaluated for m in local_models
),
)
return savings_to_dict(result)
finally:
agg.close()
@router.post("/v1/telemetry/reset")
async def reset_telemetry():
"""Clear all stored telemetry records.
Useful after updating token-counting methodology — clears
historical records that were computed under the old rules so
that the savings dashboard and leaderboard submissions start
fresh with corrected values.
"""
from openjarvis.core.config import DEFAULT_CONFIG_DIR
from openjarvis.telemetry.aggregator import TelemetryAggregator
db_path = DEFAULT_CONFIG_DIR / "telemetry.db"
if not db_path.exists():
return {"status": "ok", "records_cleared": 0}
agg = TelemetryAggregator(db_path)
try:
count = agg.clear()
finally:
agg.close()
return {"status": "ok", "records_cleared": count}
@router.get("/v1/info")
async def server_info(request: Request):
"""Return server configuration: model, agent, engine."""
agent = getattr(request.app.state, "agent", None)
agent_id = getattr(agent, "agent_id", None) if agent else None
# Fall back to configured agent name if agent didn't instantiate
if agent_id is None:
agent_id = getattr(request.app.state, "agent_name", None)
return {
"model": getattr(request.app.state, "model", ""),
"agent": agent_id,
"engine": getattr(request.app.state, "engine_name", ""),
}
@router.get("/health")
async def health(request: Request):
"""Health check endpoint."""
engine = request.app.state.engine
healthy = engine.health()
if not healthy:
raise HTTPException(status_code=503, detail="Engine unhealthy")
return {"status": "ok"}
# ---------------------------------------------------------------------------
# Channel endpoints
# ---------------------------------------------------------------------------
@router.get("/v1/channels")
async def list_channels(request: Request):
"""List available messaging channels."""
bridge = getattr(request.app.state, "channel_bridge", None)
if bridge is None:
return {"channels": [], "message": "Channel bridge not configured"}
channels = bridge.list_channels()
return {"channels": channels, "status": bridge.status().value}
@router.post("/v1/channels/send")
async def channel_send(request: Request):
"""Send a message to a channel."""
bridge = getattr(request.app.state, "channel_bridge", None)
if bridge is None:
raise HTTPException(status_code=503, detail="Channel bridge not configured")
body = await request.json()
channel_name = body.get("channel", "")
content = body.get("content", "")
conversation_id = body.get("conversation_id", "")
if not channel_name or not content:
raise HTTPException(
status_code=400,
detail="'channel' and 'content' are required",
)
ok = bridge.send(channel_name, content, conversation_id=conversation_id)
if not ok:
raise HTTPException(status_code=502, detail="Failed to send message")
return {"status": "sent", "channel": channel_name}
@router.get("/v1/channels/status")
async def channel_status(request: Request):
"""Return channel bridge connection status."""
bridge = getattr(request.app.state, "channel_bridge", None)
if bridge is None:
return {"status": "not_configured"}
return {"status": bridge.status().value}
# ---------------------------------------------------------------------------
# Security scan endpoint
# ---------------------------------------------------------------------------
@router.get("/v1/security/scan")
async def security_scan():
"""Run a read-only security environment audit and return findings."""
from openjarvis.cli.scan_cmd import PrivacyScanner
scanner = PrivacyScanner()
results = scanner.run_all()
return {
"has_warnings": any(r.status == "warn" for r in results),
"has_failures": any(r.status == "fail" for r in results),
"findings": [
{
"name": r.name,
"status": r.status,
"message": r.message,
"platform": r.platform,
}
for r in results
],
}
__all__ = ["router"]