Louis-François Bouchard commited on
Commit ·
8a9b6de
1
Parent(s): dc3936a
Use direct DeepSeek as default model
Browse files- .env.example +4 -5
- README.md +1 -1
- app/chat_service.py +9 -9
- app/config.py +5 -5
- tests/manual_e2e_langsmith.md +3 -3
- tests/test_api.py +3 -3
- tests/test_chat_service.py +12 -11
- tests/test_config.py +3 -3
.env.example
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
# To run the AI Tutor app (FastAPI backend)
|
| 2 |
-
# Default chat path: DeepSeek V4 Flash through
|
| 3 |
-
# Flash as the local application fallback if
|
| 4 |
-
|
| 5 |
GEMINI_API_KEY=...
|
| 6 |
# Gemini also accepts GOOGLE_API_KEY instead of GEMINI_API_KEY.
|
| 7 |
GOOGLE_API_KEY=...
|
|
@@ -10,8 +10,7 @@ GOOGLE_API_KEY=...
|
|
| 10 |
OPENAI_API_KEY=...
|
| 11 |
ANTHROPIC_API_KEY=...
|
| 12 |
COHERE_API_KEY=...
|
| 13 |
-
|
| 14 |
-
DEEPSEEK_API_KEY=...
|
| 15 |
|
| 16 |
# Optional: trace agent/model/tool runs in LangSmith
|
| 17 |
LANGSMITH_API_KEY=...
|
|
|
|
| 1 |
# To run the AI Tutor app (FastAPI backend)
|
| 2 |
+
# Default chat path: DeepSeek V4 Flash through the first-party DeepSeek API,
|
| 3 |
+
# with Gemini 2.5 Flash as the local application fallback if DeepSeek fails.
|
| 4 |
+
DEEPSEEK_API_KEY=...
|
| 5 |
GEMINI_API_KEY=...
|
| 6 |
# Gemini also accepts GOOGLE_API_KEY instead of GEMINI_API_KEY.
|
| 7 |
GOOGLE_API_KEY=...
|
|
|
|
| 10 |
OPENAI_API_KEY=...
|
| 11 |
ANTHROPIC_API_KEY=...
|
| 12 |
COHERE_API_KEY=...
|
| 13 |
+
OPENROUTER_API_KEY=...
|
|
|
|
| 14 |
|
| 15 |
# Optional: trace agent/model/tool runs in LangSmith
|
| 16 |
LANGSMITH_API_KEY=...
|
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. The default is `
|
| 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 `deepseek:deepseek-v4-flash`, which uses DeepSeek's first-party API and falls back in-app to `google-genai:gemini-2.5-flash` when a Gemini key is configured. Set `DEEPSEEK_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 `OPENROUTER_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,7 +72,7 @@ from .provider_events import (
|
|
| 72 |
from .config import (
|
| 73 |
BM25_INDEX_PATH,
|
| 74 |
COURSE_SOURCE_KEYS,
|
| 75 |
-
|
| 76 |
DEFAULT_SELECTED_SOURCE_KEYS,
|
| 77 |
DOCUMENT_DICT_PATH,
|
| 78 |
GEMINI_FALLBACK_MODEL_NAME,
|
|
@@ -793,8 +793,8 @@ 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
|
| 797 |
-
return bool(os.environ.get("
|
| 798 |
|
| 799 |
|
| 800 |
def _build_chat_model_client(provider_model: str, include_thoughts: bool = False):
|
|
@@ -898,15 +898,15 @@ def _build_chat_model_client(provider_model: str, include_thoughts: bool = False
|
|
| 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 !=
|
| 902 |
return _build_chat_model_client(
|
| 903 |
provider_model,
|
| 904 |
include_thoughts=include_thoughts,
|
| 905 |
)
|
| 906 |
-
if not
|
| 907 |
logger.warning(
|
| 908 |
-
"No
|
| 909 |
-
|
| 910 |
GEMINI_FALLBACK_MODEL_NAME,
|
| 911 |
)
|
| 912 |
return _build_chat_model_client(
|
|
@@ -917,9 +917,9 @@ def build_chat_model(model_name: str, include_thoughts: bool = False):
|
|
| 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
|
| 921 |
GEMINI_FALLBACK_MODEL_NAME,
|
| 922 |
-
|
| 923 |
)
|
| 924 |
return model
|
| 925 |
fallback = _build_chat_model_client(
|
|
|
|
| 72 |
from .config import (
|
| 73 |
BM25_INDEX_PATH,
|
| 74 |
COURSE_SOURCE_KEYS,
|
| 75 |
+
DEEPSEEK_DIRECT_MODEL_NAME,
|
| 76 |
DEFAULT_SELECTED_SOURCE_KEYS,
|
| 77 |
DOCUMENT_DICT_PATH,
|
| 78 |
GEMINI_FALLBACK_MODEL_NAME,
|
|
|
|
| 793 |
return bool(os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY"))
|
| 794 |
|
| 795 |
|
| 796 |
+
def _has_deepseek_key() -> bool:
|
| 797 |
+
return bool(os.environ.get("DEEPSEEK_API_KEY"))
|
| 798 |
|
| 799 |
|
| 800 |
def _build_chat_model_client(provider_model: str, include_thoughts: bool = False):
|
|
|
|
| 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_DIRECT_MODEL_NAME:
|
| 902 |
return _build_chat_model_client(
|
| 903 |
provider_model,
|
| 904 |
include_thoughts=include_thoughts,
|
| 905 |
)
|
| 906 |
+
if not _has_deepseek_key() and _has_google_genai_key():
|
| 907 |
logger.warning(
|
| 908 |
+
"No DEEPSEEK_API_KEY is set for %s; using Gemini fallback %s.",
|
| 909 |
+
DEEPSEEK_DIRECT_MODEL_NAME,
|
| 910 |
GEMINI_FALLBACK_MODEL_NAME,
|
| 911 |
)
|
| 912 |
return _build_chat_model_client(
|
|
|
|
| 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 DeepSeek only.",
|
| 921 |
GEMINI_FALLBACK_MODEL_NAME,
|
| 922 |
+
DEEPSEEK_DIRECT_MODEL_NAME,
|
| 923 |
)
|
| 924 |
return model
|
| 925 |
fallback = _build_chat_model_client(
|
app/config.py
CHANGED
|
@@ -69,14 +69,14 @@ 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 |
GEMINI_FALLBACK_MODEL_NAME = "google-genai:gemini-2.5-flash"
|
| 74 |
-
DEFAULT_MODEL_NAME =
|
| 75 |
|
| 76 |
AVAILABLE_MODELS: tuple[dict[str, str], ...] = (
|
| 77 |
{
|
| 78 |
-
"id":
|
| 79 |
-
"label": "DeepSeek V4 Flash
|
| 80 |
},
|
| 81 |
{"id": GEMINI_FALLBACK_MODEL_NAME, "label": "Gemini 2.5 Flash"},
|
| 82 |
{"id": "anthropic:claude-haiku-4-5", "label": "Claude Haiku 4.5"},
|
|
@@ -270,7 +270,7 @@ __all__ = [
|
|
| 270 |
"DEFAULT_SELECTED_SOURCE_KEYS",
|
| 271 |
"DEFAULT_SELECTED_SOURCES_UI",
|
| 272 |
"DEFAULT_MODEL_NAME",
|
| 273 |
-
"
|
| 274 |
"GEMINI_FALLBACK_MODEL_NAME",
|
| 275 |
"BM25_INDEX_PATH",
|
| 276 |
"DOCUMENT_DICT_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_DIRECT_MODEL_NAME = "deepseek:deepseek-v4-flash"
|
| 73 |
GEMINI_FALLBACK_MODEL_NAME = "google-genai:gemini-2.5-flash"
|
| 74 |
+
DEFAULT_MODEL_NAME = DEEPSEEK_DIRECT_MODEL_NAME
|
| 75 |
|
| 76 |
AVAILABLE_MODELS: tuple[dict[str, str], ...] = (
|
| 77 |
{
|
| 78 |
+
"id": DEEPSEEK_DIRECT_MODEL_NAME,
|
| 79 |
+
"label": "DeepSeek V4 Flash",
|
| 80 |
},
|
| 81 |
{"id": GEMINI_FALLBACK_MODEL_NAME, "label": "Gemini 2.5 Flash"},
|
| 82 |
{"id": "anthropic:claude-haiku-4-5", "label": "Claude Haiku 4.5"},
|
|
|
|
| 270 |
"DEFAULT_SELECTED_SOURCE_KEYS",
|
| 271 |
"DEFAULT_SELECTED_SOURCES_UI",
|
| 272 |
"DEFAULT_MODEL_NAME",
|
| 273 |
+
"DEEPSEEK_DIRECT_MODEL_NAME",
|
| 274 |
"GEMINI_FALLBACK_MODEL_NAME",
|
| 275 |
"BM25_INDEX_PATH",
|
| 276 |
"DOCUMENT_DICT_PATH",
|
tests/manual_e2e_langsmith.md
CHANGED
|
@@ -13,7 +13,7 @@ Run commands from the repository root.
|
|
| 13 |
Required local artifacts and environment:
|
| 14 |
|
| 15 |
- `.env` contains `COHERE_API_KEY`
|
| 16 |
-
- `.env` contains `
|
| 17 |
- `.env` contains `GEMINI_API_KEY` or `GOOGLE_API_KEY`
|
| 18 |
- `.env` contains `LANGSMITH_API_KEY`
|
| 19 |
- `.env` has `LANGSMITH_TRACING=true`
|
|
@@ -29,7 +29,7 @@ uv run dotenv -f .env run -- python - <<'PY'
|
|
| 29 |
import os
|
| 30 |
for key in [
|
| 31 |
"COHERE_API_KEY",
|
| 32 |
-
"
|
| 33 |
"GEMINI_API_KEY",
|
| 34 |
"GOOGLE_API_KEY",
|
| 35 |
"LANGSMITH_API_KEY",
|
|
@@ -95,7 +95,7 @@ cat >/tmp/ai_tutor_e2e_payload.json <<'JSON'
|
|
| 95 |
"transformers"
|
| 96 |
],
|
| 97 |
"enabledTools": [],
|
| 98 |
-
"model": "
|
| 99 |
"includeReasoning": true,
|
| 100 |
"threadId": ""
|
| 101 |
}
|
|
|
|
| 13 |
Required local artifacts and environment:
|
| 14 |
|
| 15 |
- `.env` contains `COHERE_API_KEY`
|
| 16 |
+
- `.env` contains `DEEPSEEK_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 |
+
"DEEPSEEK_API_KEY",
|
| 33 |
"GEMINI_API_KEY",
|
| 34 |
"GOOGLE_API_KEY",
|
| 35 |
"LANGSMITH_API_KEY",
|
|
|
|
| 95 |
"transformers"
|
| 96 |
],
|
| 97 |
"enabledTools": [],
|
| 98 |
+
"model": "deepseek:deepseek-v4-flash",
|
| 99 |
"includeReasoning": true,
|
| 100 |
"threadId": ""
|
| 101 |
}
|
tests/test_api.py
CHANGED
|
@@ -75,8 +75,8 @@ class ApiTestCase(unittest.TestCase):
|
|
| 75 |
)
|
| 76 |
self.assertEqual(transformers["label"], "Transformers Docs")
|
| 77 |
self.assertEqual(transformers["shortLabel"], "Transformers")
|
| 78 |
-
self.assertEqual(body["model"], "
|
| 79 |
-
# DeepSeek
|
| 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)
|
|
@@ -958,7 +958,7 @@ def live_chat_payload(
|
|
| 958 |
"sourceKeys": ["peft", "transformers"],
|
| 959 |
"enabledTools": enabled_tools or [],
|
| 960 |
"model": os.getenv(
|
| 961 |
-
"LIVE_API_E2E_MODEL", "
|
| 962 |
),
|
| 963 |
"includeReasoning": False,
|
| 964 |
"threadId": thread_id,
|
|
|
|
| 75 |
)
|
| 76 |
self.assertEqual(transformers["label"], "Transformers Docs")
|
| 77 |
self.assertEqual(transformers["shortLabel"], "Transformers")
|
| 78 |
+
self.assertEqual(body["model"], "deepseek:deepseek-v4-flash")
|
| 79 |
+
# DeepSeek direct 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)
|
|
|
|
| 958 |
"sourceKeys": ["peft", "transformers"],
|
| 959 |
"enabledTools": enabled_tools or [],
|
| 960 |
"model": os.getenv(
|
| 961 |
+
"LIVE_API_E2E_MODEL", "deepseek:deepseek-v4-flash"
|
| 962 |
),
|
| 963 |
"includeReasoning": False,
|
| 964 |
"threadId": thread_id,
|
tests/test_chat_service.py
CHANGED
|
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch
|
|
| 9 |
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
|
| 10 |
from langchain_core.runnables.fallbacks import RunnableWithFallbacks
|
| 11 |
|
| 12 |
-
from app.config import
|
| 13 |
from app.chat_service import (
|
| 14 |
THREAD_IDLE_TTL_SECONDS,
|
| 15 |
_claim_kb_command_budget,
|
|
@@ -318,39 +318,40 @@ class ChatServiceTestCase(unittest.TestCase):
|
|
| 318 |
self.assertGreater(reasoning.max_tokens, 8192)
|
| 319 |
self.assertEqual(reasoning.thinking, {"type": "enabled", "budget_tokens": 2048})
|
| 320 |
|
| 321 |
-
def
|
| 322 |
with patch.dict(
|
| 323 |
os.environ,
|
| 324 |
{
|
| 325 |
-
"
|
| 326 |
"GEMINI_API_KEY": "gemini-test-key",
|
| 327 |
},
|
| 328 |
clear=True,
|
| 329 |
):
|
| 330 |
-
model = build_chat_model(
|
| 331 |
|
| 332 |
self.assertIsInstance(model, RunnableWithFallbacks)
|
| 333 |
-
self.assertEqual(model.runnable.model_name, "deepseek
|
|
|
|
| 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
|
| 341 |
self,
|
| 342 |
) -> None:
|
| 343 |
with patch.dict(
|
| 344 |
os.environ,
|
| 345 |
-
{"
|
| 346 |
clear=True,
|
| 347 |
):
|
| 348 |
-
model = build_chat_model(
|
| 349 |
|
| 350 |
self.assertNotIsInstance(model, RunnableWithFallbacks)
|
| 351 |
-
self.assertEqual(model.model_name, "deepseek
|
| 352 |
|
| 353 |
-
def
|
| 354 |
self,
|
| 355 |
) -> None:
|
| 356 |
with patch.dict(
|
|
@@ -358,7 +359,7 @@ class ChatServiceTestCase(unittest.TestCase):
|
|
| 358 |
{"GEMINI_API_KEY": "gemini-test-key"},
|
| 359 |
clear=True,
|
| 360 |
):
|
| 361 |
-
model = build_chat_model(
|
| 362 |
|
| 363 |
self.assertNotIsInstance(model, RunnableWithFallbacks)
|
| 364 |
self.assertEqual(model.model, GEMINI_FALLBACK_MODEL_NAME.partition(":")[2])
|
|
|
|
| 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_DIRECT_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_direct_default_has_gemini_fallback(self) -> None:
|
| 322 |
with patch.dict(
|
| 323 |
os.environ,
|
| 324 |
{
|
| 325 |
+
"DEEPSEEK_API_KEY": "deepseek-test-key",
|
| 326 |
"GEMINI_API_KEY": "gemini-test-key",
|
| 327 |
},
|
| 328 |
clear=True,
|
| 329 |
):
|
| 330 |
+
model = build_chat_model(DEEPSEEK_DIRECT_MODEL_NAME)
|
| 331 |
|
| 332 |
self.assertIsInstance(model, RunnableWithFallbacks)
|
| 333 |
+
self.assertEqual(model.runnable.model_name, "deepseek-v4-flash")
|
| 334 |
+
self.assertEqual(str(model.runnable.openai_api_base), "https://api.deepseek.com")
|
| 335 |
self.assertEqual(len(model.fallbacks), 1)
|
| 336 |
self.assertEqual(
|
| 337 |
model.fallbacks[0].model,
|
| 338 |
GEMINI_FALLBACK_MODEL_NAME.partition(":")[2],
|
| 339 |
)
|
| 340 |
|
| 341 |
+
def test_deepseek_direct_default_skips_fallback_without_gemini_key(
|
| 342 |
self,
|
| 343 |
) -> None:
|
| 344 |
with patch.dict(
|
| 345 |
os.environ,
|
| 346 |
+
{"DEEPSEEK_API_KEY": "deepseek-test-key"},
|
| 347 |
clear=True,
|
| 348 |
):
|
| 349 |
+
model = build_chat_model(DEEPSEEK_DIRECT_MODEL_NAME)
|
| 350 |
|
| 351 |
self.assertNotIsInstance(model, RunnableWithFallbacks)
|
| 352 |
+
self.assertEqual(model.model_name, "deepseek-v4-flash")
|
| 353 |
|
| 354 |
+
def test_deepseek_direct_default_uses_gemini_when_deepseek_key_missing(
|
| 355 |
self,
|
| 356 |
) -> None:
|
| 357 |
with patch.dict(
|
|
|
|
| 359 |
{"GEMINI_API_KEY": "gemini-test-key"},
|
| 360 |
clear=True,
|
| 361 |
):
|
| 362 |
+
model = build_chat_model(DEEPSEEK_DIRECT_MODEL_NAME)
|
| 363 |
|
| 364 |
self.assertNotIsInstance(model, RunnableWithFallbacks)
|
| 365 |
self.assertEqual(model.model, GEMINI_FALLBACK_MODEL_NAME.partition(":")[2])
|
tests/test_config.py
CHANGED
|
@@ -11,9 +11,9 @@ import pytest
|
|
| 11 |
from app import config
|
| 12 |
|
| 13 |
|
| 14 |
-
def
|
| 15 |
-
assert config.DEFAULT_MODEL_NAME == config.
|
| 16 |
-
assert config.DEFAULT_MODEL_NAME == "
|
| 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(
|
|
|
|
| 11 |
from app import config
|
| 12 |
|
| 13 |
|
| 14 |
+
def test_default_chat_model_prefers_deepseek_direct_with_gemini_fallback() -> None:
|
| 15 |
+
assert config.DEFAULT_MODEL_NAME == config.DEEPSEEK_DIRECT_MODEL_NAME
|
| 16 |
+
assert config.DEFAULT_MODEL_NAME == "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(
|