Louis-François Bouchard commited on
Commit ·
dc3936a
1
Parent(s): 0000d72
Use OpenRouter DeepSeek with Gemini fallback
Browse files- .env.example +9 -5
- README.md +1 -1
- app/chat_service.py +47 -6
- app/config.py +10 -2
- tests/manual_e2e_langsmith.md +3 -1
- tests/test_api.py +20 -2
- tests/test_chat_service.py +47 -0
- tests/test_config.py +11 -0
.env.example
CHANGED
|
@@ -1,12 +1,16 @@
|
|
| 1 |
# To run the AI Tutor app (FastAPI backend)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
OPENAI_API_KEY=...
|
| 3 |
ANTHROPIC_API_KEY=...
|
| 4 |
-
GEMINI_API_KEY=...
|
| 5 |
COHERE_API_KEY=...
|
| 6 |
-
|
| 7 |
-
# Optional: open-model chat provider for cheap eval runs (e.g. deepseek:deepseek-v4-flash).
|
| 8 |
-
# OpenRouter is an OpenAI-compatible gateway; DEEPSEEK_API_KEY hits the first-party API.
|
| 9 |
-
OPENROUTER_API_KEY=...
|
| 10 |
DEEPSEEK_API_KEY=...
|
| 11 |
|
| 12 |
# Optional: trace agent/model/tool runs in LangSmith
|
|
|
|
| 1 |
# To run the AI Tutor app (FastAPI backend)
|
| 2 |
+
# Default chat path: DeepSeek V4 Flash through OpenRouter, with Gemini 2.5
|
| 3 |
+
# Flash as the local application fallback if the OpenRouter call fails.
|
| 4 |
+
OPENROUTER_API_KEY=...
|
| 5 |
+
GEMINI_API_KEY=...
|
| 6 |
+
# Gemini also accepts GOOGLE_API_KEY instead of GEMINI_API_KEY.
|
| 7 |
+
GOOGLE_API_KEY=...
|
| 8 |
+
|
| 9 |
+
# Optional alternate providers / utilities
|
| 10 |
OPENAI_API_KEY=...
|
| 11 |
ANTHROPIC_API_KEY=...
|
|
|
|
| 12 |
COHERE_API_KEY=...
|
| 13 |
+
# DEEPSEEK_API_KEY hits the first-party DeepSeek API (not the default path).
|
|
|
|
|
|
|
|
|
|
| 14 |
DEEPSEEK_API_KEY=...
|
| 15 |
|
| 16 |
# Optional: trace agent/model/tool runs in LangSmith
|
README.md
CHANGED
|
@@ -48,7 +48,7 @@ Want to build this AI tutor yourself, end to end? Our [**Full Stack AI Engineer
|
|
| 48 |
cp .env.example .env # then edit values
|
| 49 |
```
|
| 50 |
|
| 51 |
-
The chat model is provider-agnostic, configured in `provider:model` format,
|
| 52 |
To trace requests in LangSmith, set `LANGSMITH_API_KEY`. The app enables tracing automatically when that key is present unless `LANGSMITH_TRACING=false` is set.
|
| 53 |
|
| 54 |
### LangSmith Agent Tracing
|
|
|
|
| 48 |
cp .env.example .env # then edit values
|
| 49 |
```
|
| 50 |
|
| 51 |
+
The chat model is provider-agnostic, configured in `provider:model` format. The default is `openrouter:deepseek/deepseek-v4-flash`, which uses OpenRouter first and falls back in-app to `google-genai:gemini-2.5-flash` when a Gemini key is configured. Set `OPENROUTER_API_KEY` plus `GEMINI_API_KEY` or `GOOGLE_API_KEY` for the default path. Optional provider keys include `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and `DEEPSEEK_API_KEY` for non-default provider paths.
|
| 52 |
To trace requests in LangSmith, set `LANGSMITH_API_KEY`. The app enables tracing automatically when that key is present unless `LANGSMITH_TRACING=false` is set.
|
| 53 |
|
| 54 |
### LangSmith Agent Tracing
|
app/chat_service.py
CHANGED
|
@@ -72,8 +72,10 @@ from .provider_events import (
|
|
| 72 |
from .config import (
|
| 73 |
BM25_INDEX_PATH,
|
| 74 |
COURSE_SOURCE_KEYS,
|
|
|
|
| 75 |
DEFAULT_SELECTED_SOURCE_KEYS,
|
| 76 |
DOCUMENT_DICT_PATH,
|
|
|
|
| 77 |
SOURCE_KEY_TO_LABEL,
|
| 78 |
VECTOR_COLLECTION_NAME,
|
| 79 |
VECTOR_DB_DIR,
|
|
@@ -787,8 +789,15 @@ def format_tool_args(args: Any) -> str:
|
|
| 787 |
return str(args).strip()
|
| 788 |
|
| 789 |
|
| 790 |
-
def
|
| 791 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
provider, _, actual_model = provider_model.partition(":")
|
| 793 |
|
| 794 |
if provider == "openai":
|
|
@@ -798,10 +807,9 @@ def build_chat_model(model_name: str, include_thoughts: bool = False):
|
|
| 798 |
# stream_usage=True makes the streamed response carry token usage, so
|
| 799 |
# context_stats / cost telemetry populates (some OpenAI-compatible
|
| 800 |
# endpoints, e.g. Ollama, omit usage and leave token counts at 0).
|
| 801 |
-
# Routing is left
|
| 802 |
-
# provider with allow_fallbacks=False made batches die
|
| 803 |
-
# transient 429 instead of routing around it.
|
| 804 |
-
# is what keeps a long eval run reliable.
|
| 805 |
return ChatOpenAI(
|
| 806 |
model=actual_model,
|
| 807 |
temperature=1,
|
|
@@ -888,6 +896,39 @@ def build_chat_model(model_name: str, include_thoughts: bool = False):
|
|
| 888 |
)
|
| 889 |
|
| 890 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 891 |
class GeminiServerSideToolsMiddleware(AgentMiddleware):
|
| 892 |
"""Enable server-side tool invocation for Gemini 3 tool combinations.
|
| 893 |
|
|
|
|
| 72 |
from .config import (
|
| 73 |
BM25_INDEX_PATH,
|
| 74 |
COURSE_SOURCE_KEYS,
|
| 75 |
+
DEEPSEEK_OPENROUTER_MODEL_NAME,
|
| 76 |
DEFAULT_SELECTED_SOURCE_KEYS,
|
| 77 |
DOCUMENT_DICT_PATH,
|
| 78 |
+
GEMINI_FALLBACK_MODEL_NAME,
|
| 79 |
SOURCE_KEY_TO_LABEL,
|
| 80 |
VECTOR_COLLECTION_NAME,
|
| 81 |
VECTOR_DB_DIR,
|
|
|
|
| 789 |
return str(args).strip()
|
| 790 |
|
| 791 |
|
| 792 |
+
def _has_google_genai_key() -> bool:
|
| 793 |
+
return bool(os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY"))
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
def _has_openrouter_key() -> bool:
|
| 797 |
+
return bool(os.environ.get("OPENROUTER_API_KEY"))
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
def _build_chat_model_client(provider_model: str, include_thoughts: bool = False):
|
| 801 |
provider, _, actual_model = provider_model.partition(":")
|
| 802 |
|
| 803 |
if provider == "openai":
|
|
|
|
| 807 |
# stream_usage=True makes the streamed response carry token usage, so
|
| 808 |
# context_stats / cost telemetry populates (some OpenAI-compatible
|
| 809 |
# endpoints, e.g. Ollama, omit usage and leave token counts at 0).
|
| 810 |
+
# Routing inside OpenRouter is left with its own fallbacks enabled:
|
| 811 |
+
# pinning a single provider with allow_fallbacks=False made batches die
|
| 812 |
+
# on a backend's transient 429 instead of routing around it.
|
|
|
|
| 813 |
return ChatOpenAI(
|
| 814 |
model=actual_model,
|
| 815 |
temperature=1,
|
|
|
|
| 896 |
)
|
| 897 |
|
| 898 |
|
| 899 |
+
def build_chat_model(model_name: str, include_thoughts: bool = False):
|
| 900 |
+
provider_model = normalize_model_name(model_name)
|
| 901 |
+
if provider_model != DEEPSEEK_OPENROUTER_MODEL_NAME:
|
| 902 |
+
return _build_chat_model_client(
|
| 903 |
+
provider_model,
|
| 904 |
+
include_thoughts=include_thoughts,
|
| 905 |
+
)
|
| 906 |
+
if not _has_openrouter_key() and _has_google_genai_key():
|
| 907 |
+
logger.warning(
|
| 908 |
+
"No OPENROUTER_API_KEY is set for %s; using Gemini fallback %s.",
|
| 909 |
+
DEEPSEEK_OPENROUTER_MODEL_NAME,
|
| 910 |
+
GEMINI_FALLBACK_MODEL_NAME,
|
| 911 |
+
)
|
| 912 |
+
return _build_chat_model_client(
|
| 913 |
+
GEMINI_FALLBACK_MODEL_NAME,
|
| 914 |
+
include_thoughts=include_thoughts,
|
| 915 |
+
)
|
| 916 |
+
model = _build_chat_model_client(provider_model, include_thoughts=include_thoughts)
|
| 917 |
+
if not _has_google_genai_key():
|
| 918 |
+
logger.warning(
|
| 919 |
+
"Gemini fallback %s is configured for %s, but no GOOGLE_API_KEY or "
|
| 920 |
+
"GEMINI_API_KEY is set; using OpenRouter only.",
|
| 921 |
+
GEMINI_FALLBACK_MODEL_NAME,
|
| 922 |
+
DEEPSEEK_OPENROUTER_MODEL_NAME,
|
| 923 |
+
)
|
| 924 |
+
return model
|
| 925 |
+
fallback = _build_chat_model_client(
|
| 926 |
+
GEMINI_FALLBACK_MODEL_NAME,
|
| 927 |
+
include_thoughts=include_thoughts,
|
| 928 |
+
)
|
| 929 |
+
return model.with_fallbacks([fallback])
|
| 930 |
+
|
| 931 |
+
|
| 932 |
class GeminiServerSideToolsMiddleware(AgentMiddleware):
|
| 933 |
"""Enable server-side tool invocation for Gemini 3 tool combinations.
|
| 934 |
|
app/config.py
CHANGED
|
@@ -69,10 +69,16 @@ KB_INDEX_PATH = f"{KB_DIR}/wiki/index.md"
|
|
| 69 |
KB_AGENTS_PATH = f"{KB_DIR}/AGENTS.md"
|
| 70 |
# In-git template, copied into data/kb/AGENTS.md by ensure_kb_agents_md().
|
| 71 |
KB_AGENTS_TEMPLATE_PATH = "data/scraping_scripts/kb_agents_template.md"
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
|
| 74 |
AVAILABLE_MODELS: tuple[dict[str, str], ...] = (
|
| 75 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
{"id": "anthropic:claude-haiku-4-5", "label": "Claude Haiku 4.5"},
|
| 77 |
)
|
| 78 |
|
|
@@ -264,6 +270,8 @@ __all__ = [
|
|
| 264 |
"DEFAULT_SELECTED_SOURCE_KEYS",
|
| 265 |
"DEFAULT_SELECTED_SOURCES_UI",
|
| 266 |
"DEFAULT_MODEL_NAME",
|
|
|
|
|
|
|
| 267 |
"BM25_INDEX_PATH",
|
| 268 |
"DOCUMENT_DICT_PATH",
|
| 269 |
"KB_AGENTS_PATH",
|
|
|
|
| 69 |
KB_AGENTS_PATH = f"{KB_DIR}/AGENTS.md"
|
| 70 |
# In-git template, copied into data/kb/AGENTS.md by ensure_kb_agents_md().
|
| 71 |
KB_AGENTS_TEMPLATE_PATH = "data/scraping_scripts/kb_agents_template.md"
|
| 72 |
+
DEEPSEEK_OPENROUTER_MODEL_NAME = "openrouter:deepseek/deepseek-v4-flash"
|
| 73 |
+
GEMINI_FALLBACK_MODEL_NAME = "google-genai:gemini-2.5-flash"
|
| 74 |
+
DEFAULT_MODEL_NAME = DEEPSEEK_OPENROUTER_MODEL_NAME
|
| 75 |
|
| 76 |
AVAILABLE_MODELS: tuple[dict[str, str], ...] = (
|
| 77 |
+
{
|
| 78 |
+
"id": DEEPSEEK_OPENROUTER_MODEL_NAME,
|
| 79 |
+
"label": "DeepSeek V4 Flash (OpenRouter)",
|
| 80 |
+
},
|
| 81 |
+
{"id": GEMINI_FALLBACK_MODEL_NAME, "label": "Gemini 2.5 Flash"},
|
| 82 |
{"id": "anthropic:claude-haiku-4-5", "label": "Claude Haiku 4.5"},
|
| 83 |
)
|
| 84 |
|
|
|
|
| 270 |
"DEFAULT_SELECTED_SOURCE_KEYS",
|
| 271 |
"DEFAULT_SELECTED_SOURCES_UI",
|
| 272 |
"DEFAULT_MODEL_NAME",
|
| 273 |
+
"DEEPSEEK_OPENROUTER_MODEL_NAME",
|
| 274 |
+
"GEMINI_FALLBACK_MODEL_NAME",
|
| 275 |
"BM25_INDEX_PATH",
|
| 276 |
"DOCUMENT_DICT_PATH",
|
| 277 |
"KB_AGENTS_PATH",
|
tests/manual_e2e_langsmith.md
CHANGED
|
@@ -13,6 +13,7 @@ Run commands from the repository root.
|
|
| 13 |
Required local artifacts and environment:
|
| 14 |
|
| 15 |
- `.env` contains `COHERE_API_KEY`
|
|
|
|
| 16 |
- `.env` contains `GEMINI_API_KEY` or `GOOGLE_API_KEY`
|
| 17 |
- `.env` contains `LANGSMITH_API_KEY`
|
| 18 |
- `.env` has `LANGSMITH_TRACING=true`
|
|
@@ -28,6 +29,7 @@ uv run dotenv -f .env run -- python - <<'PY'
|
|
| 28 |
import os
|
| 29 |
for key in [
|
| 30 |
"COHERE_API_KEY",
|
|
|
|
| 31 |
"GEMINI_API_KEY",
|
| 32 |
"GOOGLE_API_KEY",
|
| 33 |
"LANGSMITH_API_KEY",
|
|
@@ -93,7 +95,7 @@ cat >/tmp/ai_tutor_e2e_payload.json <<'JSON'
|
|
| 93 |
"transformers"
|
| 94 |
],
|
| 95 |
"enabledTools": [],
|
| 96 |
-
"model": "
|
| 97 |
"includeReasoning": true,
|
| 98 |
"threadId": ""
|
| 99 |
}
|
|
|
|
| 13 |
Required local artifacts and environment:
|
| 14 |
|
| 15 |
- `.env` contains `COHERE_API_KEY`
|
| 16 |
+
- `.env` contains `OPENROUTER_API_KEY`
|
| 17 |
- `.env` contains `GEMINI_API_KEY` or `GOOGLE_API_KEY`
|
| 18 |
- `.env` contains `LANGSMITH_API_KEY`
|
| 19 |
- `.env` has `LANGSMITH_TRACING=true`
|
|
|
|
| 29 |
import os
|
| 30 |
for key in [
|
| 31 |
"COHERE_API_KEY",
|
| 32 |
+
"OPENROUTER_API_KEY",
|
| 33 |
"GEMINI_API_KEY",
|
| 34 |
"GOOGLE_API_KEY",
|
| 35 |
"LANGSMITH_API_KEY",
|
|
|
|
| 95 |
"transformers"
|
| 96 |
],
|
| 97 |
"enabledTools": [],
|
| 98 |
+
"model": "openrouter:deepseek/deepseek-v4-flash",
|
| 99 |
"includeReasoning": true,
|
| 100 |
"threadId": ""
|
| 101 |
}
|
tests/test_api.py
CHANGED
|
@@ -75,10 +75,25 @@ class ApiTestCase(unittest.TestCase):
|
|
| 75 |
)
|
| 76 |
self.assertEqual(transformers["label"], "Transformers Docs")
|
| 77 |
self.assertEqual(transformers["shortLabel"], "Transformers")
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
tool_keys = {tool["key"] for tool in tools}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
self.assertIn("web_search", tool_keys)
|
| 81 |
self.assertIn("url_context", tool_keys)
|
|
|
|
| 82 |
|
| 83 |
def test_list_tools_for_anthropic_model(self) -> None:
|
| 84 |
with TestClient(app) as client:
|
|
@@ -175,6 +190,7 @@ class ApiTestCase(unittest.TestCase):
|
|
| 175 |
],
|
| 176 |
"sourceKeys": ["langchain", "transformers"],
|
| 177 |
"enabledTools": ["web_search", "not_a_real_tool"],
|
|
|
|
| 178 |
"threadId": "thread_0",
|
| 179 |
}
|
| 180 |
|
|
@@ -941,7 +957,9 @@ def live_chat_payload(
|
|
| 941 |
"messages": messages,
|
| 942 |
"sourceKeys": ["peft", "transformers"],
|
| 943 |
"enabledTools": enabled_tools or [],
|
| 944 |
-
"model": os.getenv(
|
|
|
|
|
|
|
| 945 |
"includeReasoning": False,
|
| 946 |
"threadId": thread_id,
|
| 947 |
}
|
|
|
|
| 75 |
)
|
| 76 |
self.assertEqual(transformers["label"], "Transformers Docs")
|
| 77 |
self.assertEqual(transformers["shortLabel"], "Transformers")
|
| 78 |
+
self.assertEqual(body["model"], "openrouter:deepseek/deepseek-v4-flash")
|
| 79 |
+
# DeepSeek via OpenRouter is the default model, so only local KB tools
|
| 80 |
+
# are exposed until a provider with built-in web tools is selected.
|
| 81 |
tool_keys = {tool["key"] for tool in tools}
|
| 82 |
+
self.assertNotIn("web_search", tool_keys)
|
| 83 |
+
self.assertNotIn("url_context", tool_keys)
|
| 84 |
+
|
| 85 |
+
def test_list_tools_for_gemini_model(self) -> None:
|
| 86 |
+
with TestClient(app) as client:
|
| 87 |
+
response = client.get(
|
| 88 |
+
"/api/tools", params={"model": "google-genai:gemini-2.5-flash"}
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
self.assertEqual(response.status_code, 200)
|
| 92 |
+
tool_keys = {tool["key"] for tool in response.json()["tools"]}
|
| 93 |
+
self.assertIn("retrieval", tool_keys)
|
| 94 |
self.assertIn("web_search", tool_keys)
|
| 95 |
self.assertIn("url_context", tool_keys)
|
| 96 |
+
self.assertNotIn("web_fetch", tool_keys)
|
| 97 |
|
| 98 |
def test_list_tools_for_anthropic_model(self) -> None:
|
| 99 |
with TestClient(app) as client:
|
|
|
|
| 190 |
],
|
| 191 |
"sourceKeys": ["langchain", "transformers"],
|
| 192 |
"enabledTools": ["web_search", "not_a_real_tool"],
|
| 193 |
+
"model": "google-genai:gemini-2.5-flash",
|
| 194 |
"threadId": "thread_0",
|
| 195 |
}
|
| 196 |
|
|
|
|
| 957 |
"messages": messages,
|
| 958 |
"sourceKeys": ["peft", "transformers"],
|
| 959 |
"enabledTools": enabled_tools or [],
|
| 960 |
+
"model": os.getenv(
|
| 961 |
+
"LIVE_API_E2E_MODEL", "openrouter:deepseek/deepseek-v4-flash"
|
| 962 |
+
),
|
| 963 |
"includeReasoning": False,
|
| 964 |
"threadId": thread_id,
|
| 965 |
}
|
tests/test_chat_service.py
CHANGED
|
@@ -7,7 +7,9 @@ import unittest
|
|
| 7 |
from unittest.mock import MagicMock, patch
|
| 8 |
|
| 9 |
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
|
|
|
|
| 10 |
|
|
|
|
| 11 |
from app.chat_service import (
|
| 12 |
THREAD_IDLE_TTL_SECONDS,
|
| 13 |
_claim_kb_command_budget,
|
|
@@ -316,6 +318,51 @@ class ChatServiceTestCase(unittest.TestCase):
|
|
| 316 |
self.assertGreater(reasoning.max_tokens, 8192)
|
| 317 |
self.assertEqual(reasoning.thinking, {"type": "enabled", "budget_tokens": 2048})
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
def test_kb_command_budget_blocks_after_limit(self) -> None:
|
| 320 |
session_id = "test_budget_session"
|
| 321 |
_clear_kb_command_budget(session_id)
|
|
|
|
| 7 |
from unittest.mock import MagicMock, patch
|
| 8 |
|
| 9 |
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
|
| 10 |
+
from langchain_core.runnables.fallbacks import RunnableWithFallbacks
|
| 11 |
|
| 12 |
+
from app.config import DEEPSEEK_OPENROUTER_MODEL_NAME, GEMINI_FALLBACK_MODEL_NAME
|
| 13 |
from app.chat_service import (
|
| 14 |
THREAD_IDLE_TTL_SECONDS,
|
| 15 |
_claim_kb_command_budget,
|
|
|
|
| 318 |
self.assertGreater(reasoning.max_tokens, 8192)
|
| 319 |
self.assertEqual(reasoning.thinking, {"type": "enabled", "budget_tokens": 2048})
|
| 320 |
|
| 321 |
+
def test_deepseek_openrouter_default_has_gemini_fallback(self) -> None:
|
| 322 |
+
with patch.dict(
|
| 323 |
+
os.environ,
|
| 324 |
+
{
|
| 325 |
+
"OPENROUTER_API_KEY": "openrouter-test-key",
|
| 326 |
+
"GEMINI_API_KEY": "gemini-test-key",
|
| 327 |
+
},
|
| 328 |
+
clear=True,
|
| 329 |
+
):
|
| 330 |
+
model = build_chat_model(DEEPSEEK_OPENROUTER_MODEL_NAME)
|
| 331 |
+
|
| 332 |
+
self.assertIsInstance(model, RunnableWithFallbacks)
|
| 333 |
+
self.assertEqual(model.runnable.model_name, "deepseek/deepseek-v4-flash")
|
| 334 |
+
self.assertEqual(len(model.fallbacks), 1)
|
| 335 |
+
self.assertEqual(
|
| 336 |
+
model.fallbacks[0].model,
|
| 337 |
+
GEMINI_FALLBACK_MODEL_NAME.partition(":")[2],
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
def test_deepseek_openrouter_default_skips_fallback_without_gemini_key(
|
| 341 |
+
self,
|
| 342 |
+
) -> None:
|
| 343 |
+
with patch.dict(
|
| 344 |
+
os.environ,
|
| 345 |
+
{"OPENROUTER_API_KEY": "openrouter-test-key"},
|
| 346 |
+
clear=True,
|
| 347 |
+
):
|
| 348 |
+
model = build_chat_model(DEEPSEEK_OPENROUTER_MODEL_NAME)
|
| 349 |
+
|
| 350 |
+
self.assertNotIsInstance(model, RunnableWithFallbacks)
|
| 351 |
+
self.assertEqual(model.model_name, "deepseek/deepseek-v4-flash")
|
| 352 |
+
|
| 353 |
+
def test_deepseek_openrouter_default_uses_gemini_when_openrouter_key_missing(
|
| 354 |
+
self,
|
| 355 |
+
) -> None:
|
| 356 |
+
with patch.dict(
|
| 357 |
+
os.environ,
|
| 358 |
+
{"GEMINI_API_KEY": "gemini-test-key"},
|
| 359 |
+
clear=True,
|
| 360 |
+
):
|
| 361 |
+
model = build_chat_model(DEEPSEEK_OPENROUTER_MODEL_NAME)
|
| 362 |
+
|
| 363 |
+
self.assertNotIsInstance(model, RunnableWithFallbacks)
|
| 364 |
+
self.assertEqual(model.model, GEMINI_FALLBACK_MODEL_NAME.partition(":")[2])
|
| 365 |
+
|
| 366 |
def test_kb_command_budget_blocks_after_limit(self) -> None:
|
| 367 |
session_id = "test_budget_session"
|
| 368 |
_clear_kb_command_budget(session_id)
|
tests/test_config.py
CHANGED
|
@@ -11,6 +11,17 @@ import pytest
|
|
| 11 |
from app import config
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
def _patched_bundle(tmp_path: Path) -> ExitStack:
|
| 15 |
db_dir = tmp_path / "chroma"
|
| 16 |
kb_dir = tmp_path / "kb"
|
|
|
|
| 11 |
from app import config
|
| 12 |
|
| 13 |
|
| 14 |
+
def test_default_chat_model_prefers_deepseek_openrouter_with_gemini_fallback() -> None:
|
| 15 |
+
assert config.DEFAULT_MODEL_NAME == config.DEEPSEEK_OPENROUTER_MODEL_NAME
|
| 16 |
+
assert config.DEFAULT_MODEL_NAME == "openrouter:deepseek/deepseek-v4-flash"
|
| 17 |
+
assert config.GEMINI_FALLBACK_MODEL_NAME == "google-genai:gemini-2.5-flash"
|
| 18 |
+
assert config.AVAILABLE_MODELS[0]["id"] == config.DEFAULT_MODEL_NAME
|
| 19 |
+
assert any(
|
| 20 |
+
model["id"] == config.GEMINI_FALLBACK_MODEL_NAME
|
| 21 |
+
for model in config.AVAILABLE_MODELS
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
def _patched_bundle(tmp_path: Path) -> ExitStack:
|
| 26 |
db_dir = tmp_path / "chroma"
|
| 27 |
kb_dir = tmp_path / "kb"
|