Files
OpenJarvis/tests/learning/test_routing_models.py
T
555e982f51 feat: query complexity analyzer for local/cloud routing (#102)
* feat: add query complexity analyzer with CLI and UI integration

Classify incoming queries by difficulty (trivial→very_complex) to
suggest appropriate token budgets for local vs. cloud routing.

- Add score_complexity() with weighted signals (length, code, math,
  reasoning, multi-step, creative) and token tier mapping
- Wire into `jarvis ask`: auto-suggest max_tokens when not set by user,
  show complexity in --profile output, log at DEBUG level
- Add complexity metadata to /v1/chat/completions API response
- Display complexity tier and score in frontend XRayFooter
- Extend RoutingContext with complexity_score, suggested_max_tokens,
  has_reasoning fields
- Update HeuristicRouter to route on complexity_score instead of
  raw query_length
- Remove duplicated regex patterns from router.py (use complexity
  module as single source of truth)
- Add 30 unit tests for complexity module
- Fix existing router tests for new complexity-based routing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: pass temperature and max_tokens from UI settings to backend

The settings page stores temperature and max_tokens in the frontend
store, but these values were never included in the chat API request.
The backend Pydantic model defaults max_tokens to 1024 when the field
is absent, which is too low for thinking models like qwen3.5 — they
consume all tokens on reasoning and return empty content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: pass UI settings to backend and auto-bump max_tokens from complexity

- Pass temperature and max_tokens from frontend Settings store to the
  backend API (cherry-picked from fix/ui-max-tokens-passthrough)
- Server-side: bump max_tokens when the complexity analyzer suggests a
  higher budget (e.g. for thinking models on complex queries), never
  reduce below the client-requested value
- Fixes empty responses with thinking models (e.g. qwen3.5) that
  consumed all tokens on internal reasoning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: raise token budget tiers to prevent empty responses on thinking models

Double all tier budgets (trivial: 512→1024, simple: 1024→2048, etc.)
so that thinking models like qwen3.5 have enough headroom for internal
chain-of-thought plus visible output, even on simple queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: CI lint and test failures

- Break long lines in routes.py to satisfy 88-char limit (E501)
- Add intelligence.max_tokens and temperature to mocked config in
  test_ask_router.py so complexity analyzer can compare against int
  instead of MagicMock

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: line too long in test_complexity.py (E501)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:27:23 -07:00

228 lines
6.9 KiB
Python

"""Tests for router behavior with the extended model catalog."""
from __future__ import annotations
import pytest
from openjarvis.intelligence.model_catalog import register_builtin_models
from openjarvis.learning._stubs import RoutingContext
from openjarvis.learning.routing.router import (
HeuristicRouter,
build_routing_context,
)
# New local model keys for testing
NEW_LOCAL_MODELS = [
"gpt-oss:120b", # 117B total, 5.1B active, MoE
"qwen3:8b", # 8.2B, dense
"glm-4.7-flash", # 30B total, 3.0B active, MoE
"trinity-mini", # 26B total, 3.0B active, MoE
]
# Cloud model keys
CLOUD_MODELS = [
"gpt-5-mini",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-haiku-4-5",
"gemini-2.5-pro",
"gemini-3-flash",
]
def _setup_models() -> None:
"""Register builtin models needed for the tests."""
register_builtin_models()
class TestRouterWithNewModels:
"""Router behavior when using the new local models."""
def test_short_query_routes_to_smallest(self) -> None:
_setup_models()
router = HeuristicRouter(
available_models=NEW_LOCAL_MODELS,
)
ctx = RoutingContext(query="hi", query_length=2)
selected = router.select_model(ctx)
assert selected == "qwen3:8b"
def test_code_query_routes_to_largest(self) -> None:
_setup_models()
router = HeuristicRouter(
available_models=NEW_LOCAL_MODELS,
)
ctx = RoutingContext(
query="def merge_sort(arr):",
query_length=22,
has_code=True,
)
selected = router.select_model(ctx)
assert selected == "gpt-oss:120b"
def test_code_query_with_coder_available(self) -> None:
_setup_models()
models = NEW_LOCAL_MODELS + ["deepseek-coder-v2:16b"]
router = HeuristicRouter(available_models=models)
ctx = RoutingContext(
query="import numpy as np",
query_length=18,
has_code=True,
)
selected = router.select_model(ctx)
assert selected == "deepseek-coder-v2:16b"
def test_math_query_routes_to_largest(self) -> None:
_setup_models()
router = HeuristicRouter(
available_models=NEW_LOCAL_MODELS,
)
ctx = RoutingContext(
query="solve the integral of x^2 dx",
query_length=29,
has_math=True,
)
selected = router.select_model(ctx)
assert selected == "gpt-oss:120b"
def test_high_complexity_routes_to_largest(self) -> None:
_setup_models()
router = HeuristicRouter(
available_models=NEW_LOCAL_MODELS,
)
ctx = RoutingContext(query="x" * 501, query_length=501, complexity_score=0.7)
selected = router.select_model(ctx)
assert selected == "gpt-oss:120b"
def test_high_urgency_routes_to_smallest(self) -> None:
_setup_models()
router = HeuristicRouter(
available_models=NEW_LOCAL_MODELS,
)
ctx = RoutingContext(
query="solve the integral of x^2",
query_length=25,
has_math=True,
urgency=0.9,
)
selected = router.select_model(ctx)
assert selected == "qwen3:8b"
def test_reasoning_query_routes_to_largest(self) -> None:
_setup_models()
router = HeuristicRouter(
available_models=NEW_LOCAL_MODELS,
)
ctx = build_routing_context(
"Please explain step by step how neural networks learn"
)
selected = router.select_model(ctx)
assert selected == "gpt-oss:120b"
def test_medium_query_uses_default(self) -> None:
_setup_models()
router = HeuristicRouter(
available_models=NEW_LOCAL_MODELS,
default_model="glm-4.7-flash",
)
ctx = RoutingContext(
query="Tell me about the weather today",
query_length=60,
complexity_score=0.35,
)
selected = router.select_model(ctx)
assert selected == "glm-4.7-flash"
class TestRouterCloudFallback:
"""Router behavior when only cloud models are available."""
def test_no_local_falls_to_cloud(self) -> None:
_setup_models()
router = HeuristicRouter(available_models=CLOUD_MODELS)
ctx = RoutingContext(query="hi", query_length=2)
selected = router.select_model(ctx)
assert selected in CLOUD_MODELS
def test_cloud_model_selection_with_math(self) -> None:
_setup_models()
router = HeuristicRouter(available_models=CLOUD_MODELS)
ctx = RoutingContext(
query="solve x",
query_length=7,
has_math=True,
)
selected = router.select_model(ctx)
assert selected in CLOUD_MODELS
def test_empty_models_returns_fallback(self) -> None:
router = HeuristicRouter(
available_models=[],
fallback_model="gpt-5-mini",
)
ctx = RoutingContext(query="hello", query_length=5)
assert router.select_model(ctx) == "gpt-5-mini"
class TestRouterParameterized:
"""Parametrized tests for model/query combinations."""
@pytest.mark.parametrize(
"query,expected_is_largest",
[
("hi", False),
("solve the integral of sin(x)", True),
("def foo(): pass", True),
(
"Explain step by step how to solve the integral of x^2 "
"and then write code to compute the derivative",
True,
),
],
)
def test_query_type_selects_expected_size(
self,
query: str,
expected_is_largest: bool,
) -> None:
_setup_models()
router = HeuristicRouter(
available_models=NEW_LOCAL_MODELS,
)
ctx = build_routing_context(query)
selected = router.select_model(ctx)
if expected_is_largest:
assert selected == "gpt-oss:120b"
else:
assert selected == "qwen3:8b"
@pytest.mark.parametrize("model_id", NEW_LOCAL_MODELS)
def test_single_model_always_returns_it(
self,
model_id: str,
) -> None:
_setup_models()
router = HeuristicRouter(available_models=[model_id])
ctx = RoutingContext(
query="hello world",
query_length=11,
)
assert router.select_model(ctx) == model_id
@pytest.mark.parametrize("urgency", [0.85, 0.9, 1.0])
def test_high_urgency_always_smallest(
self,
urgency: float,
) -> None:
_setup_models()
router = HeuristicRouter(
available_models=NEW_LOCAL_MODELS,
)
ctx = RoutingContext(
query="complex reasoning task",
query_length=23,
has_math=True,
urgency=urgency,
)
assert router.select_model(ctx) == "qwen3:8b"