Louis-François Bouchard commited on
Commit
dc3936a
·
1 Parent(s): 0000d72

Use OpenRouter DeepSeek with Gemini fallback

Browse files
.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, for example `google-genai:gemini-3.5-flash`. Optional provider keys include `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and `GOOGLE_API_KEY`. Anthropic support is wired through the `anthropic` SDK, and Gemini support is wired through Google’s `google-genai` SDK.
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 build_chat_model(model_name: str, include_thoughts: bool = False):
791
- provider_model = normalize_model_name(model_name)
 
 
 
 
 
 
 
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 to OpenRouter with fallbacks enabled: pinning a single
802
- # provider with allow_fallbacks=False made batches die on a backend's
803
- # transient 429 instead of routing around it. Fallback across providers
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
- DEFAULT_MODEL_NAME = "google-genai:gemini-3.5-flash"
 
 
73
 
74
  AVAILABLE_MODELS: tuple[dict[str, str], ...] = (
75
- {"id": "google-genai:gemini-3.5-flash", "label": "Gemini 3.5 Flash"},
 
 
 
 
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": "google-genai:gemini-3.5-flash",
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
- # Gemini is the default model, so web search + url reading are present.
 
 
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("LIVE_API_E2E_MODEL", "google-genai:gemini-3.5-flash"),
 
 
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"