open-data-sci 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. open_data_sci-0.1.0.dist-info/METADATA +629 -0
  2. open_data_sci-0.1.0.dist-info/RECORD +85 -0
  3. open_data_sci-0.1.0.dist-info/WHEEL +4 -0
  4. open_data_sci-0.1.0.dist-info/entry_points.txt +2 -0
  5. open_data_sci-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. opendatasci/__init__.py +47 -0
  7. opendatasci/_tui/__init__.py +1 -0
  8. opendatasci/_tui/adapter.py +102 -0
  9. opendatasci/_tui/app.py +429 -0
  10. opendatasci/_tui/commands.py +95 -0
  11. opendatasci/_tui/completion.py +139 -0
  12. opendatasci/_tui/controller.py +644 -0
  13. opendatasci/_tui/file_refs.py +153 -0
  14. opendatasci/_tui/models.py +4 -0
  15. opendatasci/_tui/presenter.py +259 -0
  16. opendatasci/_tui/service.py +78 -0
  17. opendatasci/_tui/session.py +53 -0
  18. opendatasci/_tui/styles.tcss +248 -0
  19. opendatasci/_tui/styles_visible.tcss +245 -0
  20. opendatasci/_tui/theme.py +113 -0
  21. opendatasci/_tui/tools_display.py +86 -0
  22. opendatasci/_tui/widgets.py +1001 -0
  23. opendatasci/_utils/__init__.py +0 -0
  24. opendatasci/_utils/async_utils.py +11 -0
  25. opendatasci/_utils/data_formats.py +135 -0
  26. opendatasci/_utils/hash_utils.py +52 -0
  27. opendatasci/_utils/langchain_utils.py +155 -0
  28. opendatasci/_utils/streaming_utils.py +23 -0
  29. opendatasci/agents/__init__.py +12 -0
  30. opendatasci/agents/agents.py +515 -0
  31. opendatasci/agents/agents_factory.py +71 -0
  32. opendatasci/agents/chat_memory.py +397 -0
  33. opendatasci/agents/graphs.py +84 -0
  34. opendatasci/agents/nodes.py +74 -0
  35. opendatasci/agents/states.py +36 -0
  36. opendatasci/agents/turn_memory.py +124 -0
  37. opendatasci/configs.py +275 -0
  38. opendatasci/context/__init__.py +7 -0
  39. opendatasci/context/base.py +56 -0
  40. opendatasci/context/local.py +236 -0
  41. opendatasci/models/__init__.py +7 -0
  42. opendatasci/models/anthropic.py +40 -0
  43. opendatasci/models/aws.py +86 -0
  44. opendatasci/models/factory.py +179 -0
  45. opendatasci/models/google.py +79 -0
  46. opendatasci/models/local.py +79 -0
  47. opendatasci/models/microsoft.py +62 -0
  48. opendatasci/models/openai.py +49 -0
  49. opendatasci/models/providers.py +12 -0
  50. opendatasci/prompts/__init__.py +5 -0
  51. opendatasci/prompts/builders.py +85 -0
  52. opendatasci/prompts/caching.py +42 -0
  53. opendatasci/prompts/message_templates.py +7 -0
  54. opendatasci/prompts/prompt_templates.py +227 -0
  55. opendatasci/resources/skills/competitive_data_science.md +241 -0
  56. opendatasci/resources/skills/data_science.md +55 -0
  57. opendatasci/resources/skills/data_science_education.md +42 -0
  58. opendatasci/resources/skills/deep_learning.md +205 -0
  59. opendatasci/resources/skills/machine_learning.md +68 -0
  60. opendatasci/resources/skills/quantitative_analysis.md +45 -0
  61. opendatasci/sandbox/__init__.py +14 -0
  62. opendatasci/sandbox/_runner.py +114 -0
  63. opendatasci/sandbox/base.py +170 -0
  64. opendatasci/sandbox/srt.py +490 -0
  65. opendatasci/skills/__init__.py +9 -0
  66. opendatasci/skills/base.py +28 -0
  67. opendatasci/skills/local.py +131 -0
  68. opendatasci/streaming/__init__.py +37 -0
  69. opendatasci/streaming/events.py +159 -0
  70. opendatasci/streaming/processors.py +387 -0
  71. opendatasci/tools/__init__.py +58 -0
  72. opendatasci/tools/coding.py +261 -0
  73. opendatasci/tools/critic.py +136 -0
  74. opendatasci/tools/dataset_info.py +391 -0
  75. opendatasci/tools/factory.py +172 -0
  76. opendatasci/tools/mcp.py +179 -0
  77. opendatasci/tools/planning.py +88 -0
  78. opendatasci/tools/skills.py +90 -0
  79. opendatasci/tools/user_interaction.py +54 -0
  80. opendatasci/tools/web.py +236 -0
  81. opendatasci/tools/workers.py +237 -0
  82. opendatasci/tools/workspace.py +55 -0
  83. opendatasci/workspace/__init__.py +9 -0
  84. opendatasci/workspace/base.py +20 -0
  85. opendatasci/workspace/local.py +25 -0
@@ -0,0 +1,236 @@
1
+ """File-based context stores backed by the workspace's ``.opendatasci`` directory."""
2
+
3
+ import datetime
4
+ import logging
5
+ from contextlib import asynccontextmanager
6
+ from datetime import datetime as _datetime
7
+ from datetime import timezone
8
+ from pathlib import Path
9
+ from typing import AsyncGenerator, Self
10
+
11
+ from opendatasci._utils.hash_utils import hash_path
12
+ from opendatasci.context.base import BaseContextStore
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ OPENDATASCI_DIRNAME = ".opendatasci"
17
+
18
+ _NOTES_DIR = "dataset_notes"
19
+ _PROFILES_DIR = "dataset_profiling"
20
+ _PLANS_DIR = "plans"
21
+
22
+
23
+ class LocalContextStore(BaseContextStore):
24
+ """File-based context store for a single local workspace.
25
+
26
+ Persists dataset notes and profile cards (keyed by dataset path) as well as
27
+ session plans (keyed by ``session_id``). All data lives under the
28
+ workspace's ``.opendatasci`` directory.
29
+
30
+ Args:
31
+ workspace_path: Root directory of the active workspace. Relative
32
+ dataset paths are resolved against this directory.
33
+ """
34
+
35
+ def __init__(self, workspace_path: Path) -> None:
36
+ self._workspace_path = workspace_path
37
+ self._root = workspace_path / OPENDATASCI_DIRNAME
38
+ self._current_plan: str | None = None
39
+
40
+ # ── BaseContextStore: session ────────────────────────────────────
41
+
42
+ @asynccontextmanager
43
+ async def session(self) -> AsyncGenerator[Self, None]:
44
+ self._root.mkdir(parents=True, exist_ok=True)
45
+ yield self
46
+
47
+ # ── BaseContextStore: root ──────────────────────────────────────
48
+
49
+ @property
50
+ def root(self) -> Path:
51
+ return self._root
52
+
53
+ # ── Internal storage paths ───────────────────────────────────────────────
54
+
55
+ @property
56
+ def _notes_root(self) -> Path:
57
+ return self.root / _NOTES_DIR
58
+
59
+ @property
60
+ def _profiles_root(self) -> Path:
61
+ return self.root / _PROFILES_DIR
62
+
63
+ @property
64
+ def _plans_root(self) -> Path:
65
+ return self.root / _PLANS_DIR
66
+
67
+ # ── Internal notes storage ────────────────────────────────────────────────
68
+
69
+ def _find(self, hash_hex: str) -> Path | None:
70
+ """Locate an existing notes file for *hash_hex* under any date prefix."""
71
+ if self._notes_root.exists():
72
+ for p in self._notes_root.rglob(f"{hash_hex}.md"):
73
+ return p
74
+ return None
75
+
76
+ def _resolve_notes_path(self, hash_hex: str) -> Path:
77
+ """Reuse an existing file for *hash_hex*, or build a new date-keyed path."""
78
+ existing = self._find(hash_hex)
79
+ if existing is not None:
80
+ return existing
81
+ today = datetime.date.today()
82
+ return (
83
+ self._notes_root
84
+ / str(today.year)
85
+ / f"{today.month:02d}"
86
+ / f"{today.day:02d}"
87
+ / f"{hash_hex}.md"
88
+ )
89
+
90
+ def _load_notes(self, hash_hex: str) -> str | None:
91
+ p = self._find(hash_hex)
92
+ return p.read_text(encoding="utf-8") if p is not None else None
93
+
94
+ def _save_notes(self, hash_hex: str, content: str) -> None:
95
+ path = self._resolve_notes_path(hash_hex)
96
+ path.parent.mkdir(parents=True, exist_ok=True)
97
+ path.write_text(content, encoding="utf-8")
98
+
99
+ def _notes_file_path(self, hash_hex: str) -> str:
100
+ return str(self._resolve_notes_path(hash_hex))
101
+
102
+ # ── Internal profile storage ──────────────────────────────────────────────
103
+
104
+ def _load_profile(self, hash_hex: str) -> str | None:
105
+ p = self._profiles_root / f"{hash_hex}.md"
106
+ return p.read_text(encoding="utf-8") if p.exists() else None
107
+
108
+ # ── BaseContextStore interface ──────────────────────────────────
109
+
110
+ async def read_dataset_info(self, dataset_path: str) -> str:
111
+ """Return combined dataset info: profile card (if any) + session notes.
112
+
113
+ The returned Markdown string has clearly labelled sections:
114
+
115
+ ``# DATASET PROFILING`` — auto-generated stats (shape, dtypes, etc.)
116
+ ``# DATASET NOTES`` — persistent notes written by the agent
117
+
118
+ If no profile exists, only the notes section is included.
119
+ If no notes exist yet, a placeholder scaffold is returned.
120
+ """
121
+ path = self._resolve_dataset_path(dataset_path)
122
+ hash_hex = await hash_path(path)
123
+
124
+ notes = self._load_notes(hash_hex)
125
+ if notes is None:
126
+ notes = f"# DATASET NOTES: {path.name}\n\n_No notes recorded yet._\n"
127
+ else:
128
+ notes = "# DATASET NOTES\n\n" + notes
129
+
130
+ profile = self._load_profile(hash_hex)
131
+ if profile is not None:
132
+ return "# DATASET PROFILING\n\n" + profile.rstrip() + "\n\n---\n\n" + notes
133
+ return notes
134
+
135
+ async def update_dataset_info(
136
+ self,
137
+ dataset_path: str,
138
+ update: str,
139
+ merge: bool = True,
140
+ ) -> str:
141
+ """Persist dataset notes and return the path to the stored notes file.
142
+
143
+ Only notes are modified — the profile card (if any) is never touched.
144
+ """
145
+ path = self._resolve_dataset_path(dataset_path)
146
+ hash_hex = await hash_path(path)
147
+
148
+ if merge:
149
+ existing = self._load_notes(hash_hex)
150
+ new_content = (
151
+ existing.rstrip() + "\n\n" + update.strip() + "\n"
152
+ if existing
153
+ else update.strip() + "\n"
154
+ )
155
+ else:
156
+ new_content = update.strip() + "\n"
157
+
158
+ self._save_notes(hash_hex, new_content)
159
+ return self._notes_file_path(hash_hex)
160
+
161
+ async def get_profile_info(self, dataset_path: str) -> tuple[str, str, str | None]:
162
+ """Return ``(resolved_path_str, hash_hex, existing_profile_or_None)``.
163
+
164
+ Used by the ``profile_dataset`` tool to check for a cached card before
165
+ running the profiling sandbox pass.
166
+ """
167
+ path = self._resolve_dataset_path(dataset_path)
168
+ hash_hex = await hash_path(path)
169
+ return str(path), hash_hex, self._load_profile(hash_hex)
170
+
171
+ def save_dataset_profile(self, hash_hex: str, content: str) -> None:
172
+ """Persist a completed profile card for *hash_hex*."""
173
+ self._profiles_root.mkdir(parents=True, exist_ok=True)
174
+ (self._profiles_root / f"{hash_hex}.md").write_text(content, encoding="utf-8")
175
+
176
+ # ── Private helpers ───────────────────────────────────────────────────────
177
+
178
+ def _resolve_dataset_path(self, dataset_path: str) -> Path:
179
+ p = Path(dataset_path)
180
+ path = (self._workspace_path / p).resolve() if not p.is_absolute() else p.resolve()
181
+ if not path.exists():
182
+ raise FileNotFoundError(f"Dataset path does not exist: {path}")
183
+ return path
184
+
185
+ # ── BaseContextStore: plans ───────────────────────────────────────────────
186
+
187
+ def current_plan(self, session_id: str) -> str | None:
188
+ """Return the most recent plan for this session.
189
+
190
+ Resolves the plan by reading the latest ``{session_id}_*.txt`` file from
191
+ disk so the plan survives process restarts. Falls back to the in-memory
192
+ cache when no file exists yet or a disk read fails.
193
+ """
194
+ if self._plans_root.exists():
195
+ files = sorted(self._plans_root.glob(f"{session_id}_*.txt"))
196
+ if files:
197
+ try:
198
+ return files[-1].read_text(encoding="utf-8")
199
+ except OSError:
200
+ logger.warning("Could not read plan file: %s", files[-1], exc_info=True)
201
+ return self._current_plan
202
+
203
+ def save_plan(self, session_id: str, plan: str) -> None:
204
+ """Persist *plan* to disk and prune stale files.
205
+
206
+ Also updates the in-memory cache used as a fallback when no plan file
207
+ can be read back.
208
+ """
209
+ self._current_plan = plan
210
+ self._plans_root.mkdir(parents=True, exist_ok=True)
211
+ stamp = _datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
212
+ path = self._plans_root / f"{session_id}_{stamp}.txt"
213
+ try:
214
+ path.write_text(plan, encoding="utf-8")
215
+ except OSError:
216
+ logger.warning("Could not write plan file: %s", path, exc_info=True)
217
+ return
218
+ self.prune()
219
+
220
+ def prune(self) -> None:
221
+ """Keep only the most recent plan file per session_id."""
222
+ if not self._plans_root.exists():
223
+ return
224
+ by_session: dict[str, list[Path]] = {}
225
+ for p in self._plans_root.glob("*.txt"):
226
+ sid = p.stem.partition("_")[0]
227
+ if not sid:
228
+ continue
229
+ by_session.setdefault(sid, []).append(p)
230
+ for files in by_session.values():
231
+ files.sort(key=lambda f: f.name)
232
+ for stale in files[:-1]:
233
+ try:
234
+ stale.unlink()
235
+ except OSError:
236
+ logger.exception("Could not delete stale plan file: %s", stale)
@@ -0,0 +1,7 @@
1
+ from opendatasci.models.factory import (
2
+ create_model,
3
+ create_secondary_model,
4
+ )
5
+ from opendatasci.models.providers import Provider
6
+
7
+ __all__ = ["create_model", "create_secondary_model", "Provider"]
@@ -0,0 +1,40 @@
1
+ from typing import Any
2
+
3
+ from langchain_core.language_models import BaseChatModel
4
+
5
+ from opendatasci.configs import OpenDataSciConfig
6
+
7
+
8
+ def create_anthropic_model(config: OpenDataSciConfig) -> BaseChatModel:
9
+ """Instantiate a ``ChatAnthropic`` model with extended thinking enabled."""
10
+ try:
11
+ from langchain_anthropic import ChatAnthropic
12
+ except ImportError as exc:
13
+ raise ValueError("langchain-anthropic is not installed.") from exc
14
+ return ChatAnthropic(
15
+ model=config.model,
16
+ api_key=config.anthropic_api_key,
17
+ # Temperature must be 1 when extended thinking is enabled.
18
+ temperature=1,
19
+ max_tokens=16000,
20
+ thinking={"type": "enabled", "budget_tokens": config.thinking_budget},
21
+ )
22
+
23
+
24
+ def create_anthropic_secondary_model(config: OpenDataSciConfig) -> BaseChatModel:
25
+ """Instantiate a cheap ``ChatAnthropic`` model for auxiliary tasks (thinking disabled)."""
26
+ try:
27
+ from langchain_anthropic import ChatAnthropic
28
+ except ImportError as exc:
29
+ raise ValueError("langchain-anthropic is not installed.") from exc
30
+ return ChatAnthropic(
31
+ model=config.secondary_model,
32
+ api_key=config.anthropic_api_key,
33
+ temperature=0,
34
+ max_tokens=1000,
35
+ )
36
+
37
+
38
+ def cached_system_prompt(prompt: str) -> list[dict[str, Any]]:
39
+ """Wrap *prompt* with Anthropic's ephemeral cache breakpoint."""
40
+ return [{"type": "text", "text": prompt, "cache_control": {"type": "ephemeral"}}]
@@ -0,0 +1,86 @@
1
+ from typing import Any
2
+
3
+ from langchain_core.language_models import BaseChatModel
4
+ from langchain_core.messages import AIMessageChunk
5
+ from langchain_core.outputs import ChatGenerationChunk
6
+
7
+ from opendatasci.configs import OpenDataSciConfig
8
+
9
+ try:
10
+ from langchain_aws import ChatBedrockConverse as _BedrockBase
11
+ except ImportError:
12
+ _BedrockBase = None
13
+
14
+
15
+ def _strip_list_usage_fields(chunk: ChatGenerationChunk) -> ChatGenerationChunk:
16
+ """Remove list-valued fields from a chunk's usage_metadata.
17
+
18
+ Bedrock's TokenUsage includes a ``cacheDetails`` field (type: list) which
19
+ langchain_core's ``_dict_int_op`` cannot combine — it only accepts int/dict
20
+ values. Stripping the field here prevents the ValueError that would
21
+ otherwise surface as a spurious error event at the end of each streamed
22
+ response when prompt caching is active.
23
+ """
24
+ msg = chunk.message
25
+ if not (isinstance(msg, AIMessageChunk) and msg.usage_metadata):
26
+ return chunk
27
+ if not any(isinstance(v, list) for v in msg.usage_metadata.values()):
28
+ return chunk
29
+ cleaned = {k: v for k, v in msg.usage_metadata.items() if not isinstance(v, list)}
30
+ return ChatGenerationChunk(
31
+ message=msg.model_copy(update={"usage_metadata": cleaned}),
32
+ generation_info=chunk.generation_info,
33
+ )
34
+
35
+
36
+ if _BedrockBase is not None:
37
+
38
+ class _CustomBedrockConverse(_BedrockBase): # type: ignore[misc]
39
+ def _stream(
40
+ self, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any
41
+ ) -> Any:
42
+ for chunk in super()._stream(messages, stop, run_manager, **kwargs):
43
+ yield _strip_list_usage_fields(chunk)
44
+ else:
45
+ _CustomBedrockConverse = None # type: ignore[assignment,misc]
46
+
47
+
48
+ def create_bedrock_model(config: OpenDataSciConfig) -> BaseChatModel:
49
+ """Instantiate a Bedrock Converse model with adaptive thinking and streaming enabled."""
50
+ if _CustomBedrockConverse is None:
51
+ raise ValueError("langchain-aws is not installed.")
52
+ return _CustomBedrockConverse(
53
+ model=config.model,
54
+ region_name=config.aws_region,
55
+ # Temperature must be 1 when extended thinking is enabled.
56
+ temperature=1,
57
+ max_tokens=16000,
58
+ additional_model_request_fields={
59
+ "thinking": {"type": "adaptive"},
60
+ "output_config": {"effort": "medium"},
61
+ },
62
+ # langchain-aws's set_disable_streaming validator only recognises
63
+ # "claude-3" model IDs as streaming-capable. Claude 4 models
64
+ # (claude-sonnet-4-6, etc.) match none of the patterns and default to
65
+ # disable_streaming=True, causing every response to arrive as a single
66
+ # non-streamed chunk. Claude 4 supports Bedrock ConverseStream with
67
+ # tools, so we override the auto-detection explicitly.
68
+ disable_streaming=False,
69
+ )
70
+
71
+
72
+ def create_bedrock_secondary_model(config: OpenDataSciConfig) -> BaseChatModel:
73
+ """Instantiate a cheap Bedrock model for auxiliary tasks (thinking disabled)."""
74
+ if _BedrockBase is None:
75
+ raise ValueError("langchain-aws is not installed.")
76
+ return _BedrockBase( # type: ignore[no-any-return]
77
+ model=config.secondary_model,
78
+ region_name=config.aws_region,
79
+ temperature=0,
80
+ max_tokens=1000,
81
+ )
82
+
83
+
84
+ def cached_system_prompt(prompt: str) -> list[dict[str, Any]]:
85
+ """Wrap *prompt* with a Bedrock Converse cache point breakpoint."""
86
+ return [{"type": "text", "text": prompt}, {"cachePoint": {"type": "default"}}]
@@ -0,0 +1,179 @@
1
+ """
2
+ LLM provider factory for the Agent.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ import random
8
+ from typing import Any
9
+
10
+ from langchain_core.language_models import BaseChatModel
11
+
12
+ from opendatasci.configs import OpenDataSciConfig
13
+ from opendatasci.models.providers import Provider
14
+
15
+ _LOG = logging.getLogger(__name__)
16
+
17
+ _TRANSIENT_KEYWORDS = frozenset(
18
+ [
19
+ "rate limit",
20
+ "ratelimit",
21
+ "rate_limit",
22
+ "429",
23
+ "too many requests",
24
+ "overloaded",
25
+ "overload",
26
+ "503",
27
+ "service unavailable",
28
+ "throttl",
29
+ "connection error",
30
+ "connection refused",
31
+ "connecterror",
32
+ "apiconnectionerror",
33
+ ]
34
+ )
35
+
36
+ _MAX_RETRY_WAIT = 60.0
37
+ _DEFAULT_MAX_ATTEMPTS = 5
38
+
39
+
40
+ def _is_transient(exc: Exception) -> bool:
41
+ text = (type(exc).__name__ + " " + str(exc)).lower()
42
+ return any(kw in text for kw in _TRANSIENT_KEYWORDS)
43
+
44
+
45
+ class _RetryRunnable:
46
+ """Wraps a LangChain Runnable, retrying transient errors with exponential backoff."""
47
+
48
+ def __init__(self, runnable: Any, max_attempts: int = _DEFAULT_MAX_ATTEMPTS) -> None:
49
+ self._runnable = runnable
50
+ self._max_attempts = max_attempts
51
+
52
+ async def ainvoke(self, *args: Any, **kwargs: Any) -> Any:
53
+ for attempt in range(self._max_attempts):
54
+ try:
55
+ return await self._runnable.ainvoke(*args, **kwargs)
56
+ except Exception as exc:
57
+ if _is_transient(exc) and attempt < self._max_attempts - 1:
58
+ wait = min(_MAX_RETRY_WAIT, (2**attempt) + random.random())
59
+ _LOG.warning(
60
+ "LLM transient error (attempt %d/%d), retrying in %.1fs: %s",
61
+ attempt + 1,
62
+ self._max_attempts,
63
+ wait,
64
+ exc,
65
+ )
66
+ await asyncio.sleep(wait)
67
+ else:
68
+ raise
69
+
70
+
71
+ def with_retry(runnable: Any, max_attempts: int = _DEFAULT_MAX_ATTEMPTS) -> _RetryRunnable:
72
+ """Wrap *runnable* so that transient LLM errors are retried with exponential backoff."""
73
+ return _RetryRunnable(runnable, max_attempts=max_attempts)
74
+
75
+
76
+ def _supported_providers() -> str:
77
+ return ", ".join(f"'{p}'" for p in Provider)
78
+
79
+
80
+ def create_model(config: OpenDataSciConfig) -> BaseChatModel:
81
+ """Instantiate the primary LLM for the agent and workers.
82
+
83
+ Thinking is enabled by default for providers that support it (Anthropic, Bedrock).
84
+ Falls back to the provider's default primary model when none is specified.
85
+ """
86
+ match config.provider:
87
+ case Provider.ANTHROPIC:
88
+ from opendatasci.models.anthropic import create_anthropic_model as _create
89
+
90
+ return _create(config)
91
+ case Provider.BEDROCK:
92
+ from opendatasci.models.aws import create_bedrock_model as _create
93
+
94
+ return _create(config)
95
+ case Provider.OPENAI:
96
+ from opendatasci.models.openai import create_openai_model as _create
97
+
98
+ return _create(config)
99
+ case Provider.GEMINI:
100
+ from opendatasci.models.google import create_gemini_model as _create
101
+
102
+ return _create(config)
103
+ case Provider.VERTEXAI:
104
+ from opendatasci.models.google import create_vertexai_model as _create
105
+
106
+ return _create(config)
107
+ case Provider.AZURE:
108
+ from opendatasci.models.microsoft import create_azure_model as _create
109
+
110
+ return _create(config)
111
+ case Provider.OLLAMA:
112
+ from opendatasci.models.local import create_ollama_model as _create
113
+
114
+ return _create(config)
115
+ case Provider.OPENAI_COMPATIBLE_SERVER:
116
+ from opendatasci.models.local import create_openai_compatible_model as _create
117
+
118
+ return _create(config)
119
+
120
+ raise ValueError(
121
+ f"Unknown provider '{config.provider}'. Supported providers: {_supported_providers()}."
122
+ )
123
+
124
+
125
+ def create_secondary_model(config: OpenDataSciConfig) -> BaseChatModel:
126
+ """Instantiate the secondary (auxiliary) LLM for lightweight tasks such as summarization.
127
+
128
+ Thinking is disabled. Falls back to the provider's default secondary model when none is
129
+ specified.
130
+
131
+ When ``config.secondary_provider`` differs from ``config.provider``, a
132
+ lightweight config copy is used so the secondary model's factory resolves its
133
+ API key from its own environment variable rather than the primary provider's.
134
+ """
135
+ secondary_provider = config.secondary_provider
136
+
137
+ # When the secondary model uses a different provider, switch the provider
138
+ # field so the factory dispatches correctly. Each provider reads its own
139
+ # dedicated API-key field, so no credential clearing is needed.
140
+ if secondary_provider != config.provider:
141
+ config = config.model_copy(update={"provider": secondary_provider})
142
+
143
+ match secondary_provider:
144
+ case Provider.ANTHROPIC:
145
+ from opendatasci.models.anthropic import create_anthropic_secondary_model as _create
146
+
147
+ return _create(config)
148
+ case Provider.BEDROCK:
149
+ from opendatasci.models.aws import create_bedrock_secondary_model as _create
150
+
151
+ return _create(config)
152
+ case Provider.OPENAI:
153
+ from opendatasci.models.openai import create_openai_secondary_model as _create
154
+
155
+ return _create(config)
156
+ case Provider.GEMINI:
157
+ from opendatasci.models.google import create_gemini_secondary_model as _create
158
+
159
+ return _create(config)
160
+ case Provider.VERTEXAI:
161
+ from opendatasci.models.google import create_vertexai_secondary_model as _create
162
+
163
+ return _create(config)
164
+ case Provider.AZURE:
165
+ from opendatasci.models.microsoft import create_azure_secondary_model as _create
166
+
167
+ return _create(config)
168
+ case Provider.OLLAMA:
169
+ from opendatasci.models.local import create_ollama_secondary_model as _create
170
+
171
+ return _create(config)
172
+ case Provider.OPENAI_COMPATIBLE_SERVER:
173
+ from opendatasci.models.local import create_openai_compatible_secondary_model as _create
174
+
175
+ return _create(config)
176
+
177
+ raise ValueError(
178
+ f"Unknown provider '{secondary_provider}'. Supported providers: {_supported_providers()}."
179
+ )
@@ -0,0 +1,79 @@
1
+ from langchain_core.language_models import BaseChatModel
2
+
3
+ from opendatasci.configs import OpenDataSciConfig
4
+
5
+
6
+ def create_gemini_model(config: OpenDataSciConfig) -> BaseChatModel:
7
+ """Instantiate a ``ChatGoogleGenerativeAI`` model via the Gemini API."""
8
+ try:
9
+ from langchain_google_genai import ChatGoogleGenerativeAI
10
+ except ImportError as exc:
11
+ raise ValueError(
12
+ "langchain-google-genai is not installed. Run: pip install 'open-data-sci[gemini]'"
13
+ ) from exc
14
+ return ChatGoogleGenerativeAI( # type: ignore[no-any-return]
15
+ model=config.model,
16
+ google_api_key=config.google_api_key,
17
+ temperature=config.temperature,
18
+ )
19
+
20
+
21
+ def create_gemini_secondary_model(config: OpenDataSciConfig) -> BaseChatModel:
22
+ """Instantiate a cheap Gemini model for auxiliary tasks."""
23
+ try:
24
+ from langchain_google_genai import ChatGoogleGenerativeAI
25
+ except ImportError as exc:
26
+ raise ValueError(
27
+ "langchain-google-genai is not installed. Run: pip install 'open-data-sci[gemini]'"
28
+ ) from exc
29
+ return ChatGoogleGenerativeAI( # type: ignore[no-any-return]
30
+ model=config.secondary_model,
31
+ google_api_key=config.google_api_key,
32
+ temperature=0,
33
+ max_output_tokens=1000,
34
+ )
35
+
36
+
37
+ def create_vertexai_model(config: OpenDataSciConfig) -> BaseChatModel:
38
+ """Instantiate a ``ChatVertexAI`` model via Google Cloud Vertex AI."""
39
+ try:
40
+ from langchain_google_vertexai import ChatVertexAI # type: ignore[import-not-found]
41
+ except ImportError as exc:
42
+ raise ValueError(
43
+ "langchain-google-vertexai is not installed. Run: pip install 'open-data-sci[gcp]'"
44
+ ) from exc
45
+ return ChatVertexAI( # type: ignore[no-any-return]
46
+ model=config.model,
47
+ project=config.google_cloud_project,
48
+ location=config.google_cloud_location,
49
+ temperature=config.temperature,
50
+ )
51
+
52
+
53
+ def create_vertexai_secondary_model(config: OpenDataSciConfig) -> BaseChatModel:
54
+ """Instantiate a cheap Vertex AI model for auxiliary tasks."""
55
+ try:
56
+ from langchain_google_vertexai import ChatVertexAI
57
+ except ImportError as exc:
58
+ raise ValueError(
59
+ "langchain-google-vertexai is not installed. Run: pip install 'open-data-sci[gcp]'"
60
+ ) from exc
61
+ return ChatVertexAI( # type: ignore[no-any-return]
62
+ model=config.secondary_model,
63
+ project=config.google_cloud_project,
64
+ location=config.google_cloud_location,
65
+ temperature=0,
66
+ max_output_tokens=1000,
67
+ )
68
+
69
+
70
+ def cached_system_prompt(prompt: str) -> str:
71
+ """Return the system prompt unchanged.
72
+
73
+ Gemini 2.5+ models perform implicit context caching automatically for
74
+ prompts above the per-model minimum (>= 1024 tokens for Flash, >= 4096 for
75
+ Pro), keying off the request's leading prefix. No client-side cache
76
+ markers are required, and explicit `cached_content` setup is intentionally
77
+ not used here to keep the model factory side-effect free.
78
+ """
79
+ return prompt