Compare commits

...
Author SHA1 Message Date
Elliot Slusky 9b7b3681f6 ci: parallelize the test suite and cut install/coverage overhead (#580)
Run pytest with -n auto (pytest-xdist) and COVERAGE_CORE=sysmon, enable the uv cache, and switch test output to -q. Cuts the test job from ~40min to ~4min without changing what's tested or the 60% coverage gate.
2026-06-22 13:58:46 -07:00
Jon Saad-FalconandClaude Opus 4.8 6dbe5461bb fix(engine): drop Qwen3 control-token tool calls from Ollama responses (#578)
Qwen3 treats /think and /no_think as soft-switch control tokens. On small
models a multi-line prompt makes the model emit one as the sole tool argument
(e.g. {"command": "/no_think"}); OpenJarvis forwards Ollama's native tool_calls
verbatim, so the operative agent executes garbage. Filter control-token-only
tool calls in both the non-streaming generate() and streaming _run_stream()
paths, keeping legitimate calls like {"command": "date"}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:40:25 -07:00
github-actions[bot] a65592fecb chore: update clone traffic data [skip ci] 2026-06-22 08:05:29 +00:00
github-actions[bot] 0513fbdb84 chore: update clone traffic data [skip ci] 2026-06-21 07:41:30 +00:00
Elliot Slusky d4eb6308b1 Fix desktop speech dependency setup (#574) 2026-06-20 17:08:24 -07:00
github-actions[bot] 2853a0001d chore: update clone traffic data [skip ci] 2026-06-20 07:23:14 +00:00
github-actions[bot] 3c99481975 chore: update clone traffic data [skip ci] 2026-06-19 07:55:20 +00:00
23 changed files with 619 additions and 62 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "122,456",
"message": "128,077",
"color": "green",
"namedLogo": "git"
}
+7 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 122456,
"last_updated": "2026-06-18T07:44:28Z",
"total_clones": 128077,
"last_updated": "2026-06-22T08:05:29Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -84,6 +84,10 @@
"2026-06-14": 1543,
"2026-06-15": 1379,
"2026-06-16": 1317,
"2026-06-17": 1170
"2026-06-17": 1170,
"2026-06-18": 1408,
"2026-06-19": 1350,
"2026-06-20": 1437,
"2026-06-21": 1426
}
}
+12 -1
View File
@@ -23,6 +23,8 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v8.0.0
with:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev --extra framework-comparison --extra server
@@ -55,6 +57,8 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v8.0.0
with:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev --extra framework-comparison --extra server
@@ -63,8 +67,13 @@ jobs:
run: uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
- name: Run tests
# COVERAGE_CORE=sysmon uses CPython 3.12's sys.monitoring backend,
# which is dramatically cheaper than the default C trace function.
# -n auto fans the suite out across all runner cores via pytest-xdist.
env:
COVERAGE_CORE: sysmon
run: |
uv run pytest tests/ -v --tb=short -m "not live and not cloud and not hub" \
uv run pytest tests/ -n auto -q --tb=short -m "not live and not cloud and not hub" \
--cov=openjarvis \
--cov-report=term-missing \
--cov-report=xml \
@@ -107,6 +116,8 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v8.0.0
with:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev --extra server
+2 -2
View File
@@ -20,7 +20,7 @@ What it does:
4. Installs `uv` (https://astral.sh/uv) if absent.
5. Clones the OpenJarvis repository to `%LOCALAPPDATA%\OpenJarvis`
(override with `$env:OPENJARVIS_HOME`).
6. Runs `uv sync --extra server` so the FastAPI server entry point is
6. Runs `uv sync --extra desktop` so the FastAPI server and speech backend are
importable.
7. Optionally prompts to register a scheduled task that auto-starts the
server at logon.
@@ -105,7 +105,7 @@ To pull the latest:
```powershell
cd "$env:LOCALAPPDATA\OpenJarvis\src"
git pull --ff-only
uv sync --extra server
uv sync --extra desktop
```
Or re-run the installer with `-Force`:
+5 -5
View File
@@ -16,8 +16,8 @@
4. Install uv (https://astral.sh/uv) if absent.
5. Clone the OpenJarvis repository to $env:LOCALAPPDATA\OpenJarvis
(override with $env:OPENJARVIS_HOME).
6. Run `uv sync --extra server` so the FastAPI server entry point
is importable.
6. Run `uv sync --extra desktop` so the FastAPI server and speech
backend are importable.
7. Optionally register the scheduled-task service (see
deploy/windows/jarvis-service.ps1).
@@ -279,13 +279,13 @@ if (Test-Path (Join-Path $srcDir '.git')) {
}
# ---------------------------------------------------------------------------
# 6. uv sync --extra server
# 6. uv sync --extra desktop
# ---------------------------------------------------------------------------
Write-Info "Running 'uv sync --extra server' in $srcDir (this can take a few minutes)..."
Write-Info "Running 'uv sync --extra desktop' in $srcDir (this can take a few minutes)..."
Push-Location $srcDir
try {
& $uvExe sync --extra server
& $uvExe sync --extra desktop
if ($LASTEXITCODE -ne 0) {
Write-Fail "uv sync failed with exit code $LASTEXITCODE. Check the output above."
}
+1 -1
View File
@@ -109,7 +109,7 @@ If you prefer to run each step yourself:
```bash
git clone https://github.com/open-jarvis/OpenJarvis.git
cd OpenJarvis
uv sync --extra server
uv sync --extra desktop
cd frontend && npm install && cd ..
```
+3 -2
View File
@@ -41,7 +41,7 @@ If you prefer to run each step yourself:
```bash
git clone https://github.com/open-jarvis/OpenJarvis.git
cd OpenJarvis
uv sync --extra server
uv sync --extra desktop
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
cd frontend && npm install && cd ..
```
@@ -278,6 +278,7 @@ OpenJarvis uses optional extras to keep the base installation lightweight.
| Extra | Install Command | Description |
|-------|----------------|-------------|
| `desktop` | `uv sync --extra desktop` | Desktop/API server plus local speech input |
| `server` | `uv sync --extra server` | OpenAI-compatible API server (`jarvis serve`) |
| `dev` | `uv sync --extra dev` | Development and testing tools |
| `docs` | `uv sync --extra docs` | Documentation build tools |
@@ -285,7 +286,7 @@ OpenJarvis uses optional extras to keep the base installation lightweight.
Combine extras:
```bash
uv sync --extra server --extra memory-faiss --extra inference-cloud
uv sync --extra desktop --extra memory-faiss --extra inference-cloud
```
## Setting Up an Inference Backend
+2 -2
View File
@@ -8,7 +8,7 @@ avoid a Linux VM; WSL2 remains the smoother experience for most users.
## What you get
- A PowerShell installer that probes prerequisites, installs `uv`,
clones the repo, and runs `uv sync --extra server`.
clones the repo, and runs `uv sync --extra desktop`.
- An optional Windows scheduled-task service equivalent to the systemd
unit and launchd plist.
- Loopback default — the service binds `127.0.0.1` so no API key is
@@ -38,7 +38,7 @@ The installer will:
4. Install `uv` if absent (via the official `astral.sh/uv` PowerShell
installer).
5. Clone the repo to `%LOCALAPPDATA%\OpenJarvis\src`.
6. Run `uv sync --extra server`.
6. Run `uv sync --extra desktop`.
7. Prompt to register the scheduled-task service (skip with
`-SkipService`).
+24 -6
View File
@@ -682,7 +682,7 @@ fn format_uv_sync_failure(
format!(
"`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\
Try opening a terminal in that directory and running \
`uv sync --extra server` manually for the full output.",
`uv sync --extra desktop` manually for the full output.",
root.display(),
code,
uv_sync_stderr_tail(stderr, 800),
@@ -1143,7 +1143,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
sync_cmd
.args([
"sync",
"--extra", "server",
"--extra", "desktop",
"--extra", "inference-cloud",
"--extra", "inference-google",
])
@@ -1664,11 +1664,29 @@ async fn transcribe_audio(
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
if !status.is_success() {
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|value| {
value
.get("detail")
.and_then(|detail| detail.as_str())
.map(str::to_string)
})
.filter(|detail| !detail.is_empty())
.unwrap_or(body);
return Err(format!(
"Transcription failed ({}): {}",
status.as_u16(),
detail
));
}
serde_json::from_str(&body).map_err(|e| format!("Invalid response: {}", e))
}
/// Submit savings to Supabase leaderboard.
@@ -2556,7 +2574,7 @@ mod tests {
assert!(msg.contains("exit 2"));
assert!(msg.contains("/home/u/.openjarvis/src"));
assert!(msg.contains("failed to resolve numpy==2.1.3"));
assert!(msg.contains("uv sync --extra server")); // actionable next step
assert!(msg.contains("uv sync --extra desktop")); // actionable next step
}
#[test]
+13 -1
View File
@@ -97,7 +97,13 @@ export function InputArea() {
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
const corpusSync = useResearchCorpusSync(deepResearch);
const { state: speechState, available: speechAvailable, startRecording, stopRecording } = useSpeech();
const {
state: speechState,
error: speechError,
available: speechAvailable,
startRecording,
stopRecording,
} = useSpeech();
// Abort in-flight stream when the user switches models mid-generation.
// This prevents errors from trying to continue a stream with a stale model.
@@ -122,6 +128,12 @@ export function InputArea() {
: streamState.isStreaming ? 'streaming'
: undefined;
useEffect(() => {
if (speechError) {
toast.error(speechError, { duration: 8000 });
}
}, [speechError]);
const handleMicClick = useCallback(async () => {
if (speechState === 'recording') {
try {
+13 -3
View File
@@ -317,8 +317,9 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web
audioData: Array.from(new Uint8Array(buffer)),
filename,
});
} catch {
// Fall through to fetch
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(msg || 'Transcription failed');
}
}
const formData = new FormData();
@@ -327,7 +328,16 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web
method: 'POST',
body: formData,
});
if (!res.ok) throw new Error(`Transcription failed: ${res.status}`);
if (!res.ok) {
let detail = "";
try {
const body = await res.json();
detail = typeof body.detail === 'string' ? body.detail : "";
} catch {
// Keep the status-only message below when the body is not JSON.
}
throw new Error(detail || `Transcription failed: ${res.status}`);
}
return res.json();
}
+1 -1
View File
@@ -417,7 +417,7 @@ function SelfHostedView() {
<p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
Launch the API server to get the full UI in your browser:
</p>
<CodeBlock code={"git clone https://github.com/open-jarvis/OpenJarvis.git\ncd OpenJarvis\nuv sync --extra server\njarvis serve --port 8000"} />
<CodeBlock code={"git clone https://github.com/open-jarvis/OpenJarvis.git\ncd OpenJarvis\nuv sync --extra desktop\njarvis serve --port 8000"} />
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
The chat, dashboard, energy profiling, and cost comparison all run
locally on your machine.
+8
View File
@@ -48,6 +48,7 @@ dev = [
"pytest>=8",
"pytest-asyncio>=0.24",
"pytest-cov>=5",
"pytest-xdist>=3",
"respx>=0.22",
"ruff>=0.4",
"pre-commit>=3.0",
@@ -84,6 +85,13 @@ server = [
"pydantic>=2.0",
"python-multipart>=0.0.9",
]
desktop = [
"fastapi>=0.110",
"uvicorn>=0.30",
"pydantic>=2.0",
"python-multipart>=0.0.9",
"faster-whisper>=1.0",
]
openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"]
gpu-metrics = ["pynvml>=12.0"]
energy-amd = ["amdsmi>=6.1"]
+1 -1
View File
@@ -148,7 +148,7 @@ fi
# ── 7. Install Python dependencies ──────────────────────────────────
info "Installing Python dependencies..."
uv sync --extra server --quiet 2>/dev/null || uv sync --extra server
uv sync --extra desktop --quiet 2>/dev/null || uv sync --extra desktop
ok "Python dependencies installed"
# ── 7b. Build Rust extension ──────────────────────────────────────
+42
View File
@@ -223,6 +223,47 @@ def _check_optional_deps() -> List[CheckResult]:
return results
def _check_speech_backend() -> CheckResult:
"""Check whether the configured speech backend can load."""
try:
from openjarvis.speech._discovery import get_speech_backend
config = _get_config()
backend = get_speech_backend(config)
if backend is None:
return CheckResult(
"Speech backend",
"warn",
"Not configured",
details="Install desktop dependencies with `uv sync --extra desktop`.",
)
if backend.health():
return CheckResult(
"Speech backend",
"ok",
f"{backend.backend_id} ready",
)
details = None
last_error = getattr(backend, "last_error", None)
if callable(last_error):
details = last_error()
return CheckResult(
"Speech backend",
"warn",
f"{backend.backend_id} unavailable",
details=details
or "Install desktop dependencies with `uv sync --extra desktop`.",
)
except Exception as exc:
return CheckResult(
"Speech backend",
"warn",
f"Could not check: {exc}",
)
def _check_security_profile() -> CheckResult:
"""Check if a security profile is configured."""
try:
@@ -306,6 +347,7 @@ def _run_all_checks() -> List[CheckResult]:
checks.extend(_check_models())
checks.append(_check_default_model())
checks.extend(_check_optional_deps())
checks.append(_check_speech_backend())
checks.append(_check_nodejs())
checks.append(_check_security_profile())
return checks
+67 -9
View File
@@ -22,6 +22,49 @@ from openjarvis.engine._stubs import StreamChunk
logger = logging.getLogger(__name__)
# Qwen3 treats ``/think`` and ``/no_think`` as soft-switch control tokens that
# toggle reasoning mode. Small models (e.g. qwen3:14b) fed a multi-line prompt
# sometimes emit one of these as the sole tool argument, e.g.
# ``{"command": "/no_think"}`` instead of the real command. Ollama parses that
# into a fully-formed tool_call via the model's chat template, so we have to
# drop it on our side before the agent executes garbage.
_QWEN_CONTROL_TOKENS = frozenset({"/think", "/no_think"})
def _is_control_token_only_args(raw_args: Any) -> bool:
"""Return True if tool-call arguments contain nothing but a Qwen3 token.
``raw_args`` may be a dict (Ollama's native shape) or a JSON / bare string.
A call is considered degenerate only when it carries at least one control
token and no other usable content, so legitimate calls such as
``{"command": "date"}`` or ``{"command": "echo /no_think"}`` are kept.
"""
parsed: Any = raw_args
if isinstance(raw_args, str):
try:
parsed = json.loads(raw_args)
except (json.JSONDecodeError, TypeError):
parsed = raw_args
if isinstance(parsed, str):
return parsed.strip().lower() in _QWEN_CONTROL_TOKENS
if not isinstance(parsed, dict) or not parsed:
return False
saw_token = False
for value in parsed.values():
if not isinstance(value, str):
return False # a non-string value is real content
stripped = value.strip()
if not stripped:
continue
if stripped.lower() in _QWEN_CONTROL_TOKENS:
saw_token = True
else:
return False # real string content
return saw_token
def _default_num_ctx() -> int:
"""Default context window (tokens). Override with ``JARVIS_NUM_CTX``.
@@ -168,14 +211,19 @@ class OllamaEngine(InferenceEngine):
if raw_tool_calls:
tool_calls = []
for i, tc in enumerate(raw_tool_calls):
raw_args = tc.get("function", {}).get(
"arguments",
"{}",
)
fn = tc.get("function", {})
raw_args = fn.get("arguments", "{}")
if _is_control_token_only_args(raw_args):
logger.warning(
"Dropping Qwen3 control-token tool call %s(%r)",
fn.get("name", ""),
raw_args,
)
continue
tool_calls.append(
{
"id": tc.get("id", f"call_{i}"),
"name": tc.get("function", {}).get("name", ""),
"name": fn.get("name", ""),
"arguments": (
json.dumps(raw_args)
if isinstance(raw_args, dict)
@@ -183,7 +231,8 @@ class OllamaEngine(InferenceEngine):
),
}
)
result["tool_calls"] = tool_calls
if tool_calls:
result["tool_calls"] = tool_calls
return result
async def stream(
@@ -340,14 +389,22 @@ class OllamaEngine(InferenceEngine):
# OpenAI-delta fragment shape that agent_manager_routes
# expects in _merge_tool_call_fragments.
fragments: List[Dict[str, Any]] = []
for i, tc in enumerate(raw_tool_calls):
for tc in raw_tool_calls:
fn = tc.get("function", {}) or {}
raw_args = fn.get("arguments", "{}")
if _is_control_token_only_args(raw_args):
logger.warning(
"Dropping Qwen3 control-token tool call %s(%r)",
fn.get("name", ""),
raw_args,
)
continue
args_str = (
json.dumps(raw_args)
if isinstance(raw_args, dict)
else str(raw_args)
)
i = len(fragments)
fragments.append(
{
"index": i,
@@ -359,8 +416,9 @@ class OllamaEngine(InferenceEngine):
},
}
)
yield StreamChunk(tool_calls=fragments)
finish_reason = "tool_calls"
if fragments:
yield StreamChunk(tool_calls=fragments)
finish_reason = "tool_calls"
if chunk.get("done", False):
reported_prompt = chunk.get("prompt_eval_count", 0)
+24 -2
View File
@@ -893,7 +893,15 @@ async def transcribe_speech(request: Request):
filename = getattr(audio_file, "filename", "audio.wav")
ext = filename.rsplit(".", 1)[-1] if "." in filename else "wav"
result = backend.transcribe(audio_bytes, format=ext, language=language or None)
try:
result = backend.transcribe(audio_bytes, format=ext, language=language or None)
except Exception as exc:
logger.exception("Speech transcription failed")
raise HTTPException(
status_code=500,
detail=f"Speech transcription failed: {exc}",
) from exc
return {
"text": result.text,
"language": result.language,
@@ -908,9 +916,23 @@ async def speech_health(request: Request):
backend = getattr(request.app.state, "speech_backend", None)
if backend is None:
return {"available": False, "reason": "No speech backend configured"}
try:
available = backend.health()
reason = None
except Exception as exc:
logger.exception("Speech health check failed")
available = False
reason = str(exc)
if not available and reason is None:
last_error = getattr(backend, "last_error", None)
if callable(last_error):
reason = last_error()
return {
"available": backend.health(),
"available": available,
"backend": backend.backend_id,
**({"reason": reason} if reason else {}),
}
+77 -16
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import tempfile
from typing import List, Optional
@@ -13,6 +14,13 @@ try:
except ImportError:
WhisperModel = None # type: ignore[assignment, misc]
try:
import ctranslate2
except ImportError:
ctranslate2 = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
@SpeechRegistry.register("faster-whisper")
class FasterWhisperBackend(SpeechBackend):
@@ -30,20 +38,60 @@ class FasterWhisperBackend(SpeechBackend):
self._device = device
self._compute_type = compute_type
self._model: Optional[WhisperModel] = None
self._last_error: Optional[str] = None
def _resolve_compute_type(self) -> str:
"""Pick a CTranslate2 compute type supported by the configured device."""
if ctranslate2 is None:
return self._compute_type
try:
supported = set(ctranslate2.get_supported_compute_types(self._device))
except Exception as exc:
logger.debug(
"Could not inspect CTranslate2 compute types for %s: %s",
self._device,
exc,
)
return self._compute_type
if self._compute_type in supported:
return self._compute_type
preferences = (
("int8", "float32", "int8_float32", "int16")
if self._compute_type == "float16"
else ("float32", "int8", "int8_float32", "int16")
)
fallback = next((value for value in preferences if value in supported), None)
if fallback is None:
return self._compute_type
logger.warning(
"CTranslate2 compute_type=%r is not supported on device=%r; "
"using %r instead",
self._compute_type,
self._device,
fallback,
)
return fallback
def _ensure_model(self) -> WhisperModel:
"""Lazy-load the Whisper model on first use."""
if self._model is None:
if WhisperModel is None:
raise ImportError(
self._last_error = (
"faster-whisper is not installed. "
"Install with: uv sync --extra speech"
"Install with: uv sync --extra desktop"
)
raise ImportError(self._last_error)
compute_type = self._resolve_compute_type()
self._model = WhisperModel(
self._model_size,
device=self._device,
compute_type=self._compute_type,
compute_type=compute_type,
)
self._last_error = None
return self._model
def transcribe(
@@ -54,20 +102,24 @@ class FasterWhisperBackend(SpeechBackend):
language: Optional[str] = None,
) -> TranscriptionResult:
"""Transcribe audio bytes using Faster-Whisper."""
model = self._ensure_model()
try:
model = self._ensure_model()
# Write audio to a temp file (faster-whisper needs a file path)
suffix = f".{format}" if not format.startswith(".") else format
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
tmp.write(audio)
tmp.flush()
# Write audio to a temp file (faster-whisper needs a file path)
suffix = f".{format}" if not format.startswith(".") else format
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
tmp.write(audio)
tmp.flush()
kwargs = {}
if language:
kwargs["language"] = language
kwargs = {}
if language:
kwargs["language"] = language
segments_iter, info = model.transcribe(tmp.name, **kwargs)
segments_list = list(segments_iter)
segments_iter, info = model.transcribe(tmp.name, **kwargs)
segments_list = list(segments_iter)
except Exception as exc:
self._last_error = str(exc)
raise
# Build result
text = "".join(seg.text for seg in segments_list).strip()
@@ -81,6 +133,7 @@ class FasterWhisperBackend(SpeechBackend):
for seg in segments_list
]
self._last_error = None
return TranscriptionResult(
text=text,
language=getattr(info, "language", None),
@@ -91,9 +144,17 @@ class FasterWhisperBackend(SpeechBackend):
def health(self) -> bool:
"""Check if model is loaded or loadable."""
if self._model is not None:
try:
self._ensure_model()
return True
return WhisperModel is not None
except Exception as exc:
self._last_error = str(exc)
logger.debug("Faster-Whisper health check failed: %s", exc)
return False
def last_error(self) -> Optional[str]:
"""Return the last model load or transcription error, if any."""
return self._last_error
def supported_formats(self) -> List[str]:
"""Supported audio formats (same as ffmpeg/Whisper)."""
+53
View File
@@ -10,10 +10,12 @@ from click.testing import CliRunner
from openjarvis.cli import cli
from openjarvis.cli.doctor_cmd import (
CheckResult,
_check_config_exists,
_check_default_model,
_check_nodejs,
_check_python_version,
_check_speech_backend,
)
@@ -39,6 +41,10 @@ class TestDoctorRuns:
),
patch("openjarvis.cli.doctor_cmd._check_engines", return_value=[]),
patch("openjarvis.cli.doctor_cmd._check_models", return_value=[]),
patch(
"openjarvis.cli.doctor_cmd._check_speech_backend",
return_value=CheckResult("Speech backend", "ok", "mock ready"),
),
):
result = CliRunner().invoke(cli, ["doctor"])
assert result.exit_code == 0
@@ -59,6 +65,10 @@ class TestDoctorJsonOutput:
),
patch("openjarvis.cli.doctor_cmd._check_engines", return_value=[]),
patch("openjarvis.cli.doctor_cmd._check_models", return_value=[]),
patch(
"openjarvis.cli.doctor_cmd._check_speech_backend",
return_value=CheckResult("Speech backend", "ok", "mock ready"),
),
):
result = CliRunner().invoke(cli, ["doctor", "--json"])
assert result.exit_code == 0
@@ -142,6 +152,49 @@ class TestCheckDefaultModel:
assert "auto" in result.message.lower()
class TestCheckSpeechBackend:
def test_check_speech_backend_ready(self) -> None:
backend = MagicMock()
backend.backend_id = "faster-whisper"
backend.health.return_value = True
with patch(
"openjarvis.speech._discovery.get_speech_backend",
return_value=backend,
):
result = _check_speech_backend()
assert result.status == "ok"
assert "faster-whisper" in result.message
def test_check_speech_backend_reports_load_error(self) -> None:
backend = MagicMock()
backend.backend_id = "faster-whisper"
backend.health.return_value = False
backend.last_error.return_value = "missing cublas64_12.dll"
with patch(
"openjarvis.speech._discovery.get_speech_backend",
return_value=backend,
):
result = _check_speech_backend()
assert result.status == "warn"
assert "faster-whisper unavailable" in result.message
assert result.details == "missing cublas64_12.dll"
def test_check_speech_backend_missing_uses_desktop_hint(self) -> None:
with patch(
"openjarvis.speech._discovery.get_speech_backend",
return_value=None,
):
result = _check_speech_backend()
assert result.status == "warn"
assert result.details is not None
assert "uv sync --extra desktop" in result.details
class TestCheckNodejs:
def test_check_nodejs_found(self) -> None:
"""Node.js check reports version when node is available."""
+176 -1
View File
@@ -11,7 +11,7 @@ import respx
from openjarvis.core.registry import EngineRegistry
from openjarvis.core.types import Message, Role
from openjarvis.engine._base import EngineConnectionError
from openjarvis.engine.ollama import OllamaEngine
from openjarvis.engine.ollama import OllamaEngine, _is_control_token_only_args
@pytest.fixture()
@@ -82,6 +82,181 @@ class TestOllamaHealth:
assert engine.health() is False
class TestControlTokenFilter:
"""Qwen3 ``/think`` / ``/no_think`` soft-switch tokens sometimes leak into
tool-call arguments on small models (e.g. ``{"command": "/no_think"}``).
Such a call is never valid and must be dropped before execution.
"""
@pytest.mark.parametrize(
"raw_args",
[
{"command": "/no_think"},
{"command": "/think"},
{"command": " /no_think "},
{"command": "/NO_THINK"},
"/no_think",
json.dumps({"command": "/no_think"}),
{"command": "/no_think", "note": ""},
],
)
def test_detects_control_token_only(self, raw_args) -> None:
assert _is_control_token_only_args(raw_args) is True
@pytest.mark.parametrize(
"raw_args",
[
{"command": "date"},
{"command": "echo /no_think"},
{"query": "what is /no_think"},
{"command": "date", "note": "/no_think"},
{"timeout": 30},
{},
"date",
"not json at all",
],
)
def test_keeps_legitimate_args(self, raw_args) -> None:
assert _is_control_token_only_args(raw_args) is False
class TestOllamaGenerateControlToken:
def test_generate_drops_control_token_tool_call(self, engine: OllamaEngine) -> None:
with respx.mock:
respx.post("http://testhost:11434/api/chat").mock(
return_value=httpx.Response(
200,
json={
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "shell_exec",
"arguments": {"command": "/no_think"},
}
}
],
},
"model": "qwen3:14b",
},
)
)
result = engine.generate(
[Message(role=Role.USER, content="run date")],
model="qwen3:14b",
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
)
assert not result.get("tool_calls")
def test_generate_keeps_valid_tool_call(self, engine: OllamaEngine) -> None:
with respx.mock:
respx.post("http://testhost:11434/api/chat").mock(
return_value=httpx.Response(
200,
json={
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "shell_exec",
"arguments": {"command": "date"},
}
}
],
},
"model": "qwen3:14b",
},
)
)
result = engine.generate(
[Message(role=Role.USER, content="run date")],
model="qwen3:14b",
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
)
assert len(result["tool_calls"]) == 1
assert json.loads(result["tool_calls"][0]["arguments"]) == {"command": "date"}
def test_generate_drops_only_control_token_among_many(
self, engine: OllamaEngine
) -> None:
with respx.mock:
respx.post("http://testhost:11434/api/chat").mock(
return_value=httpx.Response(
200,
json={
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "shell_exec",
"arguments": {"command": "/no_think"},
}
},
{
"function": {
"name": "shell_exec",
"arguments": {"command": "date"},
}
},
],
},
"model": "qwen3:14b",
},
)
)
result = engine.generate(
[Message(role=Role.USER, content="run date")],
model="qwen3:14b",
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
)
assert len(result["tool_calls"]) == 1
assert json.loads(result["tool_calls"][0]["arguments"]) == {"command": "date"}
class TestOllamaStreamFullControlToken:
@pytest.mark.asyncio
async def test_stream_full_drops_control_token_tool_call(
self, engine: OllamaEngine
) -> None:
lines = [
json.dumps(
{
"message": {
"content": "",
"tool_calls": [
{
"function": {
"name": "shell_exec",
"arguments": {"command": "/no_think"},
}
}
],
},
"done": True,
}
),
]
body = "\n".join(lines)
with respx.mock:
respx.post("http://testhost:11434/api/chat").mock(
return_value=httpx.Response(200, text=body)
)
chunks = []
async for chunk in engine.stream_full(
[Message(role=Role.USER, content="run date")],
model="qwen3:14b",
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
):
chunks.append(chunk)
assert all(not c.tool_calls for c in chunks)
class TestOllamaStream:
@pytest.mark.asyncio
async def test_stream_yields_content(self, engine: OllamaEngine) -> None:
+26
View File
@@ -56,6 +56,18 @@ def test_transcribe_endpoint(client, mock_speech_backend):
assert data["duration_seconds"] == 1.5
def test_transcribe_endpoint_surfaces_backend_error(client, mock_speech_backend):
mock_speech_backend.transcribe.side_effect = RuntimeError("missing cublas64_12.dll")
response = client.post(
"/v1/speech/transcribe",
files={"file": ("test.wav", b"fake audio data", "audio/wav")},
)
assert response.status_code == 500
assert "missing cublas64_12.dll" in response.json()["detail"]
def test_transcribe_no_file(client):
response = client.post("/v1/speech/transcribe")
assert response.status_code == 400 or response.status_code == 422
@@ -69,6 +81,20 @@ def test_health_endpoint(client):
assert data["backend"] == "mock"
def test_health_endpoint_includes_unavailable_reason(client, mock_speech_backend):
mock_speech_backend.health.return_value = False
mock_speech_backend.last_error.return_value = (
"Install with: uv sync --extra desktop"
)
response = client.get("/v1/speech/health")
assert response.status_code == 200
data = response.json()
assert data["available"] is False
assert data["reason"] == "Install with: uv sync --extra desktop"
def test_health_no_backend():
from fastapi import FastAPI
from fastapi.testclient import TestClient
+48 -4
View File
@@ -53,17 +53,61 @@ def test_faster_whisper_transcribe():
assert result.duration_seconds == 1.5
def test_faster_whisper_falls_back_from_unsupported_float16():
mock_model = MagicMock()
with (
patch(
"openjarvis.speech.faster_whisper.WhisperModel",
return_value=mock_model,
) as mock_whisper,
patch(
"openjarvis.speech.faster_whisper.ctranslate2",
MagicMock(
get_supported_compute_types=MagicMock(return_value={"float32", "int8"})
),
),
):
backend = FasterWhisperBackend(
model_size="base",
device="cpu",
compute_type="float16",
)
assert backend._ensure_model() is mock_model
mock_whisper.assert_called_once_with("base", device="cpu", compute_type="int8")
def test_faster_whisper_missing_dependency_hint_uses_desktop_extra():
with patch("openjarvis.speech.faster_whisper.WhisperModel", new=None):
backend = FasterWhisperBackend()
with pytest.raises(ImportError) as excinfo:
backend._ensure_model()
assert "uv sync --extra desktop" in str(excinfo.value)
assert "uv sync --extra speech" not in str(excinfo.value)
def test_faster_whisper_health_no_model():
"""Health returns False before model is loaded."""
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
new=None,
):
from openjarvis.speech.faster_whisper import FasterWhisperBackend
backend = FasterWhisperBackend.__new__(FasterWhisperBackend)
backend._model = None
backend = FasterWhisperBackend()
assert backend.health() is False
assert "uv sync --extra desktop" in (backend.last_error() or "")
def test_faster_whisper_health_captures_load_error():
with patch(
"openjarvis.speech.faster_whisper.WhisperModel",
side_effect=RuntimeError("missing cublas64_12.dll"),
):
backend = FasterWhisperBackend()
assert backend.health() is False
assert "missing cublas64_12.dll" in (backend.last_error() or "")
def test_faster_whisper_supported_formats():
Generated
+13 -1
View File
@@ -5065,6 +5065,13 @@ docs = [
{ name = "mkdocs-material" },
{ name = "mkdocstrings", extra = ["python"] },
]
desktop = [
{ name = "fastapi" },
{ name = "faster-whisper" },
{ name = "pydantic" },
{ name = "python-multipart" },
{ name = "uvicorn" },
]
energy-all = [
{ name = "amdsmi" },
{ name = "nvidia-ml-py" },
@@ -5200,7 +5207,9 @@ requires-dist = [
{ name = "docker", marker = "extra == 'sandbox-docker'", specifier = ">=7.0" },
{ name = "dspy", marker = "extra == 'learning-dspy'", specifier = ">=2.6" },
{ name = "faiss-cpu", marker = "extra == 'memory-faiss'", specifier = ">=1.7" },
{ name = "fastapi", marker = "extra == 'desktop'", specifier = ">=0.110" },
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.110" },
{ name = "faster-whisper", marker = "extra == 'desktop'", specifier = ">=1.0" },
{ name = "faster-whisper", marker = "extra == 'speech'", specifier = ">=1.0" },
{ name = "gepa", marker = "extra == 'learning-gepa'", specifier = ">=0.1" },
{ name = "google-api-python-client", marker = "extra == 'channel-gmail'", specifier = ">=2.0" },
@@ -5237,6 +5246,7 @@ requires-dist = [
{ name = "posthog", specifier = ">=3.0" },
{ name = "praw", marker = "extra == 'channel-reddit'", specifier = ">=7.0" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0" },
{ name = "pydantic", marker = "extra == 'desktop'", specifier = ">=2.0" },
{ name = "pydantic", marker = "extra == 'server'", specifier = ">=2.0" },
{ name = "pygemma", marker = "extra == 'inference-gemma'", specifier = ">=0.1.3" },
{ name = "pymessenger", marker = "extra == 'channel-messenger'", specifier = ">=0.0.7" },
@@ -5244,6 +5254,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5" },
{ name = "python-multipart", marker = "extra == 'desktop'", specifier = ">=0.0.9" },
{ name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.9" },
{ name = "python-telegram-bot", specifier = ">=22.6" },
{ name = "python-telegram-bot", marker = "extra == 'channel-telegram'", specifier = ">=21.0" },
@@ -5264,6 +5275,7 @@ requires-dist = [
{ name = "transformers", marker = "extra == 'orchestrator-training'", specifier = ">=4.40" },
{ name = "twilio", marker = "extra == 'channel-twilio'", specifier = ">=9.0" },
{ name = "twitchio", marker = "extra == 'channel-twitch'", specifier = ">=2.6" },
{ name = "uvicorn", marker = "extra == 'desktop'", specifier = ">=0.30" },
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.30" },
{ name = "viberbot", marker = "extra == 'channel-viber'", specifier = ">=1.0" },
{ name = "vllm", marker = "extra == 'inference-vllm'", specifier = ">=0.16.0" },
@@ -5274,7 +5286,7 @@ requires-dist = [
{ name = "zeus-ml", extras = ["apple"], marker = "extra == 'energy-apple'" },
{ name = "zulip", marker = "extra == 'channel-zulip'", specifier = ">=0.9" },
]
provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitter", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "browser", "media", "mining-pearl-vllm", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "mining-pearl-cpu", "framework-comparison", "docs"]
provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "desktop", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitter", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "browser", "media", "mining-pearl-vllm", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "mining-pearl-cpu", "framework-comparison", "docs"]
[package.metadata.requires-dev]
dev = [{ name = "maturin", specifier = ">=1.12.6" }]