Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Tavily Search provider** — `tavily/basic` and `tavily/advanced` as Router paths. Returns web search results wrapped in an OpenAI-compatible ChatCompletion shim so Thompson Sampling can compete Tavily against LLMs on web research goals. Set `TAVILY_API_KEY` env var.

## [1.9.3] - 2026-03-29

### Fixed
Expand Down
76 changes: 76 additions & 0 deletions kalibr/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ class TraceHandle:
path: Optional[str]


def _make_chat_completion(content: str, model: str = "shim") -> Any:
"""Create an OpenAI-compatible ChatCompletion shim for non-LLM providers (e.g. search APIs)."""
from types import SimpleNamespace
import uuid as _uuid
message = SimpleNamespace(content=content, role="assistant", tool_calls=None)
choice = SimpleNamespace(message=message, finish_reason="stop", index=0)
usage = SimpleNamespace(prompt_tokens=0, completion_tokens=0, total_tokens=0)
return SimpleNamespace(
choices=[choice],
model=model,
usage=usage,
id=f"shim-{_uuid.uuid4().hex[:8]}",
object="chat.completion",
)


class Router:
"""
Routes LLM requests to the best model based on learned outcomes.
Expand Down Expand Up @@ -914,6 +930,7 @@ def _call_deepgram_stt(
"anthropic/": "anthropic",
"google/": "google",
"deepseek/": "deepseek",
"tavily/": "tavily",
}

def _dispatch(
Expand Down Expand Up @@ -943,6 +960,8 @@ def _dispatch(
return self._call_google(bare_model, messages, tools, **kwargs)
elif vendor == "deepseek":
return self._call_deepseek(bare_model, messages, tools, **kwargs)
elif vendor == "tavily":
return self._call_tavily(bare_model, messages, tools, **kwargs)

# Standard prefix checks for bare model IDs
if model_id.startswith(("gpt-", "o1-", "o3-")):
Expand Down Expand Up @@ -1109,6 +1128,63 @@ def _huggingface_to_openai_response(self, response: Any, model: str) -> Any:
),
)

def _call_tavily(self, model: str, messages: List[Dict], tools: Any, **kwargs) -> Any:
"""Call Tavily Search API and return an OpenAI-compatible ChatCompletion shim.

Supports tavily/basic and tavily/advanced search depths.
Thompson Sampling can compete Tavily against LLMs on web_scraping/research goals.
"""
try:
import httpx
except ImportError:
raise ImportError("Install 'httpx' package: pip install httpx")

api_key = os.environ.get("TAVILY_API_KEY")
if not api_key:
raise EnvironmentError(
"TAVILY_API_KEY environment variable not set.\n"
"Get your API key from: https://app.tavily.com"
)

# Extract query from last user message
query = ""
for msg in reversed(messages):
if msg.get("role") == "user":
query = msg.get("content", "")
break
if not query:
return _make_chat_completion("No user message found to search.", model=f"tavily/{model}")

# model suffix: "basic" (default) or "advanced"
search_depth = "advanced" if model == "advanced" else "basic"

resp = httpx.post(
"https://api.tavily.com/search",
json={
"api_key": api_key,
"query": query,
"search_depth": search_depth,
"include_answer": True,
"max_results": 5,
},
timeout=30.0,
)
resp.raise_for_status()
data = resp.json()

parts = []
answer = data.get("answer")
if answer:
parts.append(answer)
for r in data.get("results", [])[:5]:
title = r.get("title", "")
url = r.get("url", "")
snippet = r.get("content", "")
parts.append(f"- [{title}]({url}): {snippet}")

content = "\n\n".join(parts) if parts else "No results found."
return _make_chat_completion(content, model=f"tavily/{model}")

def _anthropic_to_openai_response(self, response: Any, model: str) -> Any:
"""Convert Anthropic response to OpenAI format."""
from types import SimpleNamespace
Expand Down
46 changes: 46 additions & 0 deletions tests/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,52 @@ def test_routes_to_anthropic(self, mock_anthropic):
mock_anthropic.assert_called_once()


class TestTavilyRouting:
@patch("kalibr.router.Router._call_tavily")
def test_routes_tavily_basic(self, mock_tavily):
mock_tavily.return_value = MagicMock()
router = Router(goal="test", paths=["tavily/basic"], auto_register=False)
router._dispatch("tavily/basic", [{"role": "user", "content": "AI news"}], None)
mock_tavily.assert_called_once()
assert mock_tavily.call_args[0][0] == "basic"

@patch("kalibr.router.Router._call_tavily")
def test_routes_tavily_advanced(self, mock_tavily):
mock_tavily.return_value = MagicMock()
router = Router(goal="test", paths=["tavily/advanced"], auto_register=False)
router._dispatch("tavily/advanced", [{"role": "user", "content": "AI news"}], None)
mock_tavily.assert_called_once()
assert mock_tavily.call_args[0][0] == "advanced"

def test_tavily_missing_api_key(self):
import os
router = Router(goal="test", paths=["tavily/basic"], auto_register=False)
env_backup = os.environ.pop("TAVILY_API_KEY", None)
try:
with pytest.raises(EnvironmentError, match="TAVILY_API_KEY"):
router._call_tavily("basic", [{"role": "user", "content": "test"}], None)
finally:
if env_backup:
os.environ["TAVILY_API_KEY"] = env_backup

@patch("httpx.post")
def test_tavily_response_shape(self, mock_post):
import os
os.environ["TAVILY_API_KEY"] = "tvly-test"
mock_post.return_value = MagicMock(
json=lambda: {
"answer": "AI is advancing rapidly.",
"results": [{"title": "AI News", "url": "https://example.com", "content": "details"}]
}
)
mock_post.return_value.raise_for_status = lambda: None
router = Router(goal="test", paths=["tavily/basic"], auto_register=False)
resp = router._call_tavily("basic", [{"role": "user", "content": "AI news"}], None)
assert hasattr(resp, "choices")
assert resp.choices[0].message.content.startswith("AI is advancing rapidly.")
os.environ.pop("TAVILY_API_KEY", None)


class TestRouterReport:
def test_double_report_warning(self):
router = Router(goal="test", auto_register=False)
Expand Down
Loading