sqlsaber-notebook 0.2.0__tar.gz

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 (37) hide show
  1. sqlsaber_notebook-0.2.0/.gitignore +12 -0
  2. sqlsaber_notebook-0.2.0/CHANGELOG.md +10 -0
  3. sqlsaber_notebook-0.2.0/PKG-INFO +124 -0
  4. sqlsaber_notebook-0.2.0/README.md +112 -0
  5. sqlsaber_notebook-0.2.0/pyproject.toml +37 -0
  6. sqlsaber_notebook-0.2.0/pytest.ini +5 -0
  7. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/__init__.py +29 -0
  8. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/_shared.py +41 -0
  9. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/analyst.py +202 -0
  10. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/capability.py +540 -0
  11. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/cli.py +169 -0
  12. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/execution/__init__.py +79 -0
  13. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/execution/_files.py +102 -0
  14. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/execution/base.py +238 -0
  15. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/execution/docker.py +514 -0
  16. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/execution/fake.py +156 -0
  17. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/execution/modal.py +550 -0
  18. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/history.py +77 -0
  19. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/prompts.py +37 -0
  20. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/rendering.py +327 -0
  21. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/result.py +48 -0
  22. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/session.py +251 -0
  23. sqlsaber_notebook-0.2.0/src/sqlsaber_notebook/tools.py +124 -0
  24. sqlsaber_notebook-0.2.0/tests/_notebooks.py +103 -0
  25. sqlsaber_notebook-0.2.0/tests/test_notebook_analyst.py +118 -0
  26. sqlsaber_notebook-0.2.0/tests/test_notebook_capability.py +419 -0
  27. sqlsaber_notebook-0.2.0/tests/test_notebook_cli.py +56 -0
  28. sqlsaber_notebook-0.2.0/tests/test_notebook_docker.py +88 -0
  29. sqlsaber_notebook-0.2.0/tests/test_notebook_docker_integration.py +103 -0
  30. sqlsaber_notebook-0.2.0/tests/test_notebook_execution.py +156 -0
  31. sqlsaber_notebook-0.2.0/tests/test_notebook_history.py +112 -0
  32. sqlsaber_notebook-0.2.0/tests/test_notebook_modal.py +132 -0
  33. sqlsaber_notebook-0.2.0/tests/test_notebook_modal_integration.py +84 -0
  34. sqlsaber_notebook-0.2.0/tests/test_notebook_rendering.py +121 -0
  35. sqlsaber_notebook-0.2.0/tests/test_notebook_session.py +114 -0
  36. sqlsaber_notebook-0.2.0/tests/test_notebook_tools.py +137 -0
  37. sqlsaber_notebook-0.2.0/uv.lock +3546 -0
@@ -0,0 +1,12 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ *.local.*
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/SarthakJariwala/sqlsaber/compare/sqlsaber-notebook-v0.1.0...sqlsaber-notebook-v0.2.0) (2026-07-22)
4
+
5
+
6
+ ### Features
7
+
8
+ * add sandboxed notebook analysis ([#199](https://github.com/SarthakJariwala/sqlsaber/issues/199)) ([c82590f](https://github.com/SarthakJariwala/sqlsaber/commit/c82590ffa8cb93649f021d548ff4d4c12171e54e))
9
+ * trigger release ([145d163](https://github.com/SarthakJariwala/sqlsaber/commit/145d163c91989e62b722a266842b13e9145be638))
10
+ * visualization model override support via api and prompt improvements ([35a506b](https://github.com/SarthakJariwala/sqlsaber/commit/35a506b23337dd64f8f20606e7e547755eae6f21))
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlsaber-notebook
3
+ Version: 0.2.0
4
+ Summary: Notebook analysis subagent for SQLsaber
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: nbformat<6,>=5.10
7
+ Requires-Dist: pillow<13,>=11
8
+ Requires-Dist: sqlsaber>=0.69.0
9
+ Provides-Extra: modal
10
+ Requires-Dist: modal<2,>=1.4; extra == 'modal'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # SQLsaber Notebook
14
+
15
+ Notebook-specific data-analysis subagent for SQLsaber.
16
+
17
+ Implemented components:
18
+
19
+ - provider-neutral notebook execution contract,
20
+ - hardened local Docker execution (default),
21
+ - explicit remote Modal Sandbox execution through the optional `modal` extra,
22
+ - fresh-kernel transactional notebook sessions,
23
+ - bounded notebook/image rendering and history collapse,
24
+ - `list_workspace` and `edit_cell` analyst tools,
25
+ - a Pydantic AI notebook analyst,
26
+ - a managed SQLsaber `analyze_data` capability, and
27
+ - the standalone `sqlsaber-notebook` CLI.
28
+
29
+ When installed with SQLsaber, the main agent can hand prior successful SQL results to
30
+ `analyze_data` for multi-step calculations, statistics, transformations, and plots.
31
+ The terminal displays the bounded executed notebook and plot previews before the main
32
+ agent's text response. Notebook bytes and images are not sent to the parent model.
33
+ Managed SDK applications can persist the notebook, plots, and generated files through
34
+ `SQLSaberOptions.artifact_publisher`; only the publisher's durable references are
35
+ stored in tool metadata and exposed through `SQLSaberResult.artifacts`.
36
+
37
+ The default balanced runtime targets larger EDA and classical ML: 4 CPUs, 8 GiB
38
+ memory, and up to 100 MiB per input/250 MiB total. SQLsaber does not cap model
39
+ requests, notebook cell count, the analyst loop, or the whole operation. Individual
40
+ cells retain a 10-minute timeout so a stuck computation can be diagnosed without
41
+ ending the overall analysis. These are fixed product defaults rather than CLI tuning
42
+ flags. Use an immutable custom image through `SQLSABER_NOTEBOOK_IMAGE` when
43
+ additional ML libraries are required.
44
+
45
+ ## Managed SQLsaber usage
46
+
47
+ ```bash
48
+ uv tool install --with sqlsaber-notebook sqlsaber
49
+ saber
50
+ ```
51
+
52
+ Docker is the default local backend. Select Modal explicitly because query results
53
+ will be uploaded to a third party:
54
+
55
+ ```bash
56
+ SQLSABER_NOTEBOOK_BACKEND=modal saber
57
+ ```
58
+
59
+ Configure a dedicated analyst model with:
60
+
61
+ ```bash
62
+ saber models set --agent notebook
63
+ ```
64
+
65
+ For a web backend, inject an application-owned artifact publisher and pass tenant
66
+ scope as run metadata:
67
+
68
+ ```python
69
+ from sqlsaber import FilesystemArtifactPublisher, SQLSaber, SQLSaberOptions
70
+
71
+ options = SQLSaberOptions(
72
+ database="sqlite:///analytics.db",
73
+ artifact_publisher=FilesystemArtifactPublisher("/private/artifacts"),
74
+ )
75
+
76
+ async with SQLSaber(options=options) as saber:
77
+ result = await saber.query(
78
+ "Analyze and plot revenue anomalies",
79
+ conversation_id="conversation-123",
80
+ metadata={"tenant_id": "acme"},
81
+ )
82
+ print(result.artifacts)
83
+ ```
84
+
85
+ Implement the cloud-neutral `ArtifactPublisher` protocol to use S3, GCS, Azure
86
+ Blob Storage, or another bucket. Return stable object references rather than
87
+ expiring signed URLs.
88
+
89
+ ## Standalone usage
90
+
91
+ ```bash
92
+ uv run sqlsaber-notebook \
93
+ --model anthropic:claude-sonnet-4-6 \
94
+ --backend docker \
95
+ --output analysis.ipynb \
96
+ "Compare revenue by region and explain material anomalies" data.csv
97
+ ```
98
+
99
+ Modal is never selected as an automatic fallback. Select it explicitly because local
100
+ files will be uploaded to Modal:
101
+
102
+ ```bash
103
+ modal setup
104
+ SQLSABER_NOTEBOOK_BACKEND=modal uv run sqlsaber-notebook \
105
+ --model anthropic:claude-sonnet-4-6 \
106
+ "Analyze this dataset" data.csv
107
+ ```
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ uv sync
113
+ uv run pytest plugins/notebook/tests -q
114
+ ```
115
+
116
+ Run live backend integration tests explicitly:
117
+
118
+ ```bash
119
+ SQLSABER_RUN_DOCKER_INTEGRATION=1 \
120
+ uv run pytest plugins/notebook/tests/test_notebook_docker_integration.py -q
121
+
122
+ SQLSABER_RUN_MODAL_INTEGRATION=1 \
123
+ uv run pytest plugins/notebook/tests/test_notebook_modal_integration.py -q
124
+ ```
@@ -0,0 +1,112 @@
1
+ # SQLsaber Notebook
2
+
3
+ Notebook-specific data-analysis subagent for SQLsaber.
4
+
5
+ Implemented components:
6
+
7
+ - provider-neutral notebook execution contract,
8
+ - hardened local Docker execution (default),
9
+ - explicit remote Modal Sandbox execution through the optional `modal` extra,
10
+ - fresh-kernel transactional notebook sessions,
11
+ - bounded notebook/image rendering and history collapse,
12
+ - `list_workspace` and `edit_cell` analyst tools,
13
+ - a Pydantic AI notebook analyst,
14
+ - a managed SQLsaber `analyze_data` capability, and
15
+ - the standalone `sqlsaber-notebook` CLI.
16
+
17
+ When installed with SQLsaber, the main agent can hand prior successful SQL results to
18
+ `analyze_data` for multi-step calculations, statistics, transformations, and plots.
19
+ The terminal displays the bounded executed notebook and plot previews before the main
20
+ agent's text response. Notebook bytes and images are not sent to the parent model.
21
+ Managed SDK applications can persist the notebook, plots, and generated files through
22
+ `SQLSaberOptions.artifact_publisher`; only the publisher's durable references are
23
+ stored in tool metadata and exposed through `SQLSaberResult.artifacts`.
24
+
25
+ The default balanced runtime targets larger EDA and classical ML: 4 CPUs, 8 GiB
26
+ memory, and up to 100 MiB per input/250 MiB total. SQLsaber does not cap model
27
+ requests, notebook cell count, the analyst loop, or the whole operation. Individual
28
+ cells retain a 10-minute timeout so a stuck computation can be diagnosed without
29
+ ending the overall analysis. These are fixed product defaults rather than CLI tuning
30
+ flags. Use an immutable custom image through `SQLSABER_NOTEBOOK_IMAGE` when
31
+ additional ML libraries are required.
32
+
33
+ ## Managed SQLsaber usage
34
+
35
+ ```bash
36
+ uv tool install --with sqlsaber-notebook sqlsaber
37
+ saber
38
+ ```
39
+
40
+ Docker is the default local backend. Select Modal explicitly because query results
41
+ will be uploaded to a third party:
42
+
43
+ ```bash
44
+ SQLSABER_NOTEBOOK_BACKEND=modal saber
45
+ ```
46
+
47
+ Configure a dedicated analyst model with:
48
+
49
+ ```bash
50
+ saber models set --agent notebook
51
+ ```
52
+
53
+ For a web backend, inject an application-owned artifact publisher and pass tenant
54
+ scope as run metadata:
55
+
56
+ ```python
57
+ from sqlsaber import FilesystemArtifactPublisher, SQLSaber, SQLSaberOptions
58
+
59
+ options = SQLSaberOptions(
60
+ database="sqlite:///analytics.db",
61
+ artifact_publisher=FilesystemArtifactPublisher("/private/artifacts"),
62
+ )
63
+
64
+ async with SQLSaber(options=options) as saber:
65
+ result = await saber.query(
66
+ "Analyze and plot revenue anomalies",
67
+ conversation_id="conversation-123",
68
+ metadata={"tenant_id": "acme"},
69
+ )
70
+ print(result.artifacts)
71
+ ```
72
+
73
+ Implement the cloud-neutral `ArtifactPublisher` protocol to use S3, GCS, Azure
74
+ Blob Storage, or another bucket. Return stable object references rather than
75
+ expiring signed URLs.
76
+
77
+ ## Standalone usage
78
+
79
+ ```bash
80
+ uv run sqlsaber-notebook \
81
+ --model anthropic:claude-sonnet-4-6 \
82
+ --backend docker \
83
+ --output analysis.ipynb \
84
+ "Compare revenue by region and explain material anomalies" data.csv
85
+ ```
86
+
87
+ Modal is never selected as an automatic fallback. Select it explicitly because local
88
+ files will be uploaded to Modal:
89
+
90
+ ```bash
91
+ modal setup
92
+ SQLSABER_NOTEBOOK_BACKEND=modal uv run sqlsaber-notebook \
93
+ --model anthropic:claude-sonnet-4-6 \
94
+ "Analyze this dataset" data.csv
95
+ ```
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ uv sync
101
+ uv run pytest plugins/notebook/tests -q
102
+ ```
103
+
104
+ Run live backend integration tests explicitly:
105
+
106
+ ```bash
107
+ SQLSABER_RUN_DOCKER_INTEGRATION=1 \
108
+ uv run pytest plugins/notebook/tests/test_notebook_docker_integration.py -q
109
+
110
+ SQLSABER_RUN_MODAL_INTEGRATION=1 \
111
+ uv run pytest plugins/notebook/tests/test_notebook_modal_integration.py -q
112
+ ```
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "sqlsaber-notebook"
3
+ version = "0.2.0"
4
+ description = "Notebook analysis subagent for SQLsaber"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "sqlsaber>=0.69.0",
9
+ "nbformat>=5.10,<6",
10
+ "pillow>=11,<13",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ modal = ["modal>=1.4,<2"]
15
+
16
+ [tool.uv.sources]
17
+ sqlsaber = { path = "../..", editable = true }
18
+
19
+ [project.scripts]
20
+ sqlsaber-notebook = "sqlsaber_notebook.cli:main"
21
+
22
+ [project.entry-points."sqlsaber.capabilities"]
23
+ notebook = "sqlsaber_notebook.capability:capability"
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/sqlsaber_notebook"]
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "pytest>=9.0.2",
35
+ "pytest-asyncio>=1.0.0",
36
+ "modal>=1.4,<2",
37
+ ]
@@ -0,0 +1,5 @@
1
+ [pytest]
2
+ asyncio_mode = auto
3
+ asyncio_default_fixture_loop_scope = function
4
+ markers =
5
+ integration: exercises a real external execution backend
@@ -0,0 +1,29 @@
1
+ """SQLsaber notebook analysis plugin."""
2
+
3
+ from .execution import (
4
+ DEFAULT_NOTEBOOK_BACKEND,
5
+ DEFAULT_NOTEBOOK_IMAGE,
6
+ ArtifactInfo,
7
+ DockerNotebookBackend,
8
+ ExecutionLimits,
9
+ NotebookBackend,
10
+ NotebookEnvironment,
11
+ NotebookExecutionResult,
12
+ NotebookInput,
13
+ resolve_notebook_backend,
14
+ resolve_notebook_image,
15
+ )
16
+
17
+ __all__ = [
18
+ "ArtifactInfo",
19
+ "DEFAULT_NOTEBOOK_BACKEND",
20
+ "DEFAULT_NOTEBOOK_IMAGE",
21
+ "DockerNotebookBackend",
22
+ "ExecutionLimits",
23
+ "NotebookBackend",
24
+ "NotebookEnvironment",
25
+ "NotebookExecutionResult",
26
+ "NotebookInput",
27
+ "resolve_notebook_backend",
28
+ "resolve_notebook_image",
29
+ ]
@@ -0,0 +1,41 @@
1
+ """Central limits and defaults for notebook analysis."""
2
+
3
+ from .execution import DEFAULT_NOTEBOOK_IMAGE, ExecutionLimits
4
+
5
+ # Balanced defaults target larger EDA and classical ML while retaining finite
6
+ # model-context, host-memory, runtime, and cost boundaries.
7
+ MAX_DEFAULT_RESULTS = 20
8
+ MAX_WORKSPACE_FILES = 50
9
+ MAX_WORKSPACE_FILE_BYTES = 100 * 1024 * 1024
10
+ MAX_WORKSPACE_TOTAL_BYTES = 250 * 1024 * 1024
11
+ MAX_CELL_SOURCE_CHARS = 30_000
12
+ MAX_TOTAL_SOURCE_CHARS = 200_000
13
+ MAX_OUTPUT_CHARS = 6_000
14
+ MAX_SNAPSHOT_CHARS = 80_000
15
+ MAX_SNAPSHOT_IMAGES = 12
16
+ MAX_IMAGE_BYTES = 4 * 1024 * 1024
17
+ MAX_SNAPSHOT_IMAGE_BYTES = 24 * 1024 * 1024
18
+ MAX_HISTORY_IMAGE_BYTES = 48 * 1024 * 1024
19
+ MAX_IMAGE_WIDTH = 2048
20
+ MAX_IMAGE_HEIGHT = 2048
21
+ MAX_GOAL_CHARS = 20_000
22
+ DEFAULT_EXECUTION_LIMITS = ExecutionLimits()
23
+
24
+ __all__ = [
25
+ "DEFAULT_EXECUTION_LIMITS",
26
+ "DEFAULT_NOTEBOOK_IMAGE",
27
+ "MAX_CELL_SOURCE_CHARS",
28
+ "MAX_GOAL_CHARS",
29
+ "MAX_HISTORY_IMAGE_BYTES",
30
+ "MAX_IMAGE_BYTES",
31
+ "MAX_IMAGE_HEIGHT",
32
+ "MAX_IMAGE_WIDTH",
33
+ "MAX_OUTPUT_CHARS",
34
+ "MAX_SNAPSHOT_CHARS",
35
+ "MAX_SNAPSHOT_IMAGE_BYTES",
36
+ "MAX_SNAPSHOT_IMAGES",
37
+ "MAX_TOTAL_SOURCE_CHARS",
38
+ "MAX_WORKSPACE_FILES",
39
+ "MAX_WORKSPACE_FILE_BYTES",
40
+ "MAX_WORKSPACE_TOTAL_BYTES",
41
+ ]
@@ -0,0 +1,202 @@
1
+ """SQLsaber-independent notebook analyst orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import hashlib
8
+ from functools import partial
9
+
10
+ from pydantic_ai import Agent
11
+ from pydantic_ai.capabilities import ProcessHistory
12
+ from pydantic_ai.models import Model
13
+ from pydantic_ai.models.anthropic import AnthropicModelSettings
14
+ from pydantic_ai.settings import ModelSettings
15
+ from pydantic_ai.usage import RunUsage, UsageLimits
16
+
17
+ from ._shared import (
18
+ DEFAULT_EXECUTION_LIMITS,
19
+ MAX_GOAL_CHARS,
20
+ MAX_SNAPSHOT_IMAGE_BYTES,
21
+ MAX_SNAPSHOT_IMAGES,
22
+ )
23
+ from .execution import (
24
+ ExecutionLimits,
25
+ NotebookBackend,
26
+ resolve_notebook_backend,
27
+ resolve_notebook_image,
28
+ )
29
+ from .history import collapse_old_snapshots
30
+ from .prompts import ANALYST_SYSTEM_PROMPT, goal_prompt
31
+ from .rendering import extract_notebook_images, normalize_png_bytes
32
+ from .result import AnalysisResult, ArtifactRef, Workspace
33
+ from .session import NotebookSession
34
+ from .tools import analyst_toolset
35
+
36
+
37
+ def build_analyst_agent(
38
+ model: Model | str,
39
+ *,
40
+ model_provider: str,
41
+ ) -> Agent[NotebookSession, str]:
42
+ provider = model_provider.strip().lower()
43
+ return Agent(
44
+ model,
45
+ deps_type=NotebookSession,
46
+ output_type=str,
47
+ instructions=ANALYST_SYSTEM_PROMPT,
48
+ model_settings=_cache_settings(provider),
49
+ capabilities=[
50
+ ProcessHistory(
51
+ partial(
52
+ collapse_old_snapshots,
53
+ cache=provider == "anthropic",
54
+ )
55
+ )
56
+ ],
57
+ toolsets=[analyst_toolset()],
58
+ )
59
+
60
+
61
+ async def analyze(
62
+ goal: str,
63
+ workspace: Workspace,
64
+ *,
65
+ model: Model | str,
66
+ model_provider: str,
67
+ backend: NotebookBackend | str | None = None,
68
+ image: str | None = None,
69
+ include_snapshot_images: bool = False,
70
+ collect_files: bool = True,
71
+ execution_limits: ExecutionLimits = DEFAULT_EXECUTION_LIMITS,
72
+ usage_limits: UsageLimits | None = None,
73
+ parent_usage: RunUsage | None = None,
74
+ ) -> AnalysisResult:
75
+ """Run one bounded notebook-analysis environment and always clean it up."""
76
+
77
+ if not goal.strip():
78
+ raise ValueError("Analysis goal cannot be empty")
79
+ if len(goal) > MAX_GOAL_CHARS:
80
+ raise ValueError(f"Analysis goal exceeds {MAX_GOAL_CHARS} characters")
81
+ selected_backend = (
82
+ backend
83
+ if isinstance(backend, NotebookBackend)
84
+ else resolve_notebook_backend(backend)
85
+ )
86
+ session = NotebookSession(
87
+ workspace=workspace,
88
+ backend=selected_backend,
89
+ image=resolve_notebook_image(image),
90
+ execution_limits=execution_limits,
91
+ include_snapshot_images=include_snapshot_images,
92
+ )
93
+ agent = build_analyst_agent(model, model_provider=model_provider)
94
+ child_usage = RunUsage()
95
+ try:
96
+ await session.ensure_environment()
97
+ result = await agent.run(
98
+ goal_prompt(goal),
99
+ deps=session,
100
+ usage=child_usage,
101
+ usage_limits=usage_limits,
102
+ )
103
+ images, files = await _harvest_artifacts(
104
+ session,
105
+ collect_files=collect_files,
106
+ )
107
+ return AnalysisResult(
108
+ answer=result.output,
109
+ notebook=session.notebook_bytes(),
110
+ images=images,
111
+ files=files,
112
+ provenance=_infer_provenance(session),
113
+ )
114
+ finally:
115
+ if parent_usage is not None:
116
+ parent_usage.incr(child_usage)
117
+ await _bounded_shielded_close(session)
118
+
119
+
120
+ def supports_notebook_images(model_name: str, model_provider: str) -> bool:
121
+ """Conservative allowlist for analyst snapshot image input."""
122
+
123
+ provider = model_provider.strip().lower()
124
+ name = model_name.lower()
125
+ if provider == "anthropic":
126
+ return "claude" in name
127
+ if provider == "google":
128
+ return "gemini" in name
129
+ if provider == "openai":
130
+ return any(token in name for token in ("gpt-4", "gpt-5", "o3", "o4"))
131
+ return False
132
+
133
+
134
+ def _cache_settings(provider: str) -> ModelSettings:
135
+ if provider == "anthropic":
136
+ return AnthropicModelSettings(
137
+ parallel_tool_calls=False,
138
+ anthropic_cache_instructions=True,
139
+ anthropic_cache_tool_definitions=True,
140
+ )
141
+ return ModelSettings(parallel_tool_calls=False)
142
+
143
+
144
+ async def _harvest_artifacts(
145
+ session: NotebookSession,
146
+ *,
147
+ collect_files: bool,
148
+ ) -> tuple[list[bytes], list[ArtifactRef]]:
149
+ images = extract_notebook_images(session.outputs)
150
+ image_hashes = {hashlib.sha256(image).hexdigest() for image in images}
151
+ image_bytes = sum(map(len, images))
152
+ files: list[ArtifactRef] = []
153
+ if session.environment is None:
154
+ return images, files
155
+
156
+ for artifact in session.artifacts:
157
+ is_png = artifact.media_type == "image/png" or artifact.path.lower().endswith(
158
+ ".png"
159
+ )
160
+ if not is_png and not collect_files:
161
+ continue
162
+ data = await session.environment.read_artifact(artifact)
163
+ if is_png:
164
+ normalized = normalize_png_bytes(data)
165
+ if normalized is None:
166
+ continue
167
+ digest = hashlib.sha256(normalized).hexdigest()
168
+ if digest in image_hashes or len(images) >= MAX_SNAPSHOT_IMAGES:
169
+ continue
170
+ if image_bytes + len(normalized) > MAX_SNAPSHOT_IMAGE_BYTES:
171
+ continue
172
+ image_hashes.add(digest)
173
+ image_bytes += len(normalized)
174
+ images.append(normalized)
175
+ elif collect_files:
176
+ files.append(
177
+ ArtifactRef(
178
+ name=artifact.path,
179
+ data=data,
180
+ media_type=artifact.media_type or "application/octet-stream",
181
+ )
182
+ )
183
+ return images, files
184
+
185
+
186
+ def _infer_provenance(session: NotebookSession) -> list[str]:
187
+ provenance: list[str] = []
188
+ for item in session.workspace.files:
189
+ token = f"../inputs/{item.name}"
190
+ if any(token in source or item.name in source for source in session.cells):
191
+ provenance.append(f"input:{item.name}")
192
+ provenance.extend(
193
+ f"cell:{index}" for index, source in enumerate(session.cells) if source.strip()
194
+ )
195
+ return provenance
196
+
197
+
198
+ async def _bounded_shielded_close(session: NotebookSession) -> None:
199
+ task = asyncio.create_task(session.close())
200
+ with contextlib.suppress(Exception):
201
+ async with asyncio.timeout(30):
202
+ await asyncio.shield(task)