Skip to content

Commit bd81111

Browse files
authored
perf(startup): defer provider SDK imports to first use (#372)
2 parents dab1184 + e5c1887 commit bd81111

11 files changed

Lines changed: 115 additions & 16 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ For each release we list user-facing changes grouped as **Added**, **Changed**,
1010

1111
## [Unreleased]
1212

13+
### Changed
14+
15+
- **Provider SDKs load on first use instead of at module import** (#370). `import notebook_intelligence` no longer imports `litellm`, `openai`, `ollama`, or the `anthropic` SDK; `litellm`, `openai`, and `anthropic` load the first time their provider is actually used (for Claude mode that includes the client construction and model refresh NBI runs at startup), while `ollama` still loads during extension startup when the provider enumerates local models. This roughly halves the server-extension import time (a cost the Jupyter server pays on every start), with the biggest effect on Windows machines where antivirus scanning amplifies the many-small-file SDK imports (#368). When NBI does load litellm, it now defaults `LITELLM_LOCAL_MODEL_COST_MAP=true` so litellm reads its bundled model-cost map rather than fetching it over HTTP at import; set the env var to `false` to restore the fetch.
16+
1317
## [5.1.0] - UNRELEASED
1418

1519
5.1.0 builds on 5.0.x with a focus on Claude-mode agent visibility. Tool calls the agent runs now render as persistent status cards with inline diffs and collapsible grouping, the generating indicator can cycle custom verbs, and cancelling a turn tears down the whole process tree the agent spawned instead of leaking it. It also adds two opt-in security guardrails (an MCP stdio-command allowlist and a default-token-password check on shared filesystems) and an always-visible mode for chat feedback. No traitlet, env-var, REST route, or on-disk-format renames or removals; every new admin surface is opt-in and listed below.

docs/admin-guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ The full surface, in one table.
122122
| `NBI_GHE_SUBDOMAIN` | str | `""` | env | GitHub Enterprise subdomain for GitHub Copilot users on a GHE tenant. Empty selects github.com. |
123123
| `NBI_GITHUB_ENTERPRISE_HOSTS` | csv | `""` | env | Comma-separated hostnames the plugin marketplace detector treats as GitHub. Cookie-domain shape: bare token (`github.acme.com`) matches exactly; leading-dot token (`.acme.com`) matches any subdomain of `acme.com`. Independent of `NBI_GHE_SUBDOMAIN`, which only configures the Copilot OAuth tenant. Required so `allow_github_plugin_import = False` actually gates GHE marketplace adds and so the `GITHUB_TOKEN` / `gh auth token` chain injects on GHE sources. |
124124
| `NBI_LOG_LEVEL` | str | `INFO` | env | Python logging level for the `notebook_intelligence` logger. |
125+
| `LITELLM_LOCAL_MODEL_COST_MAP` | bool | `true` (NBI default) | env | litellm setting that NBI defaults to `true` when it loads litellm, so litellm reads the model-cost map bundled with the installed package instead of fetching it over HTTP (litellm's own default), which stalls on proxied networks. Set to `false` before starting JupyterLab to restore litellm's remote fetch. |
125126
| `NBI_DISABLE_OUTPUT_SCRUB` | bool | unset | env | When set (`1` / `true` / `yes` / `on`), disables the shell-tool output scrubber so raw stdout/stderr (including any env-var values that leak) is sent through to chat. Default off; the scrubber redacts values for sensitive-named env vars (`TOKEN`, `SECRET`, `API_KEY`, ...) plus tokens with well-known credential prefixes (`ghp_`, `sk-ant-`, `AKIA`, ...). Opt out only when debugging credential helpers where the redaction interferes. |
126127
| `GITHUB_TOKEN`, `GH_TOKEN` | str | unset | env | Used (in that order) by user-initiated skill imports and GitHub-sourced plugin marketplace adds for GitHub auth. Falls back to `gh` CLI auth. |
127128
| `NBI_*_POLICY` | str | `user-choice` | env | Lock individual Settings panel toggles. See [README → Admin policies](../README.md#admin-policies) for the full list of `*_POLICY` env vars and matching traitlets, including `NBI_SKILLS_MANAGEMENT_POLICY`, `NBI_CLAUDE_MCP_MANAGEMENT_POLICY`, `NBI_CLAUDE_PLUGINS_MANAGEMENT_POLICY`, `NBI_TERMINAL_DRAG_DROP_POLICY`, and `NBI_REFRESH_OPEN_FILES_ON_DISK_CHANGE_POLICY`. |

notebook_intelligence/claude.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,21 @@
1010
from queue import Queue
1111
import threading
1212
import time
13-
from typing import Any, Optional
13+
from typing import Any, Optional, TYPE_CHECKING
1414
import uuid
1515
import re
1616
from anyio.abc import Process
17-
from anthropic import Anthropic
1817
from notebook_intelligence.api import AskUserQuestionData, BackendMessageType, CancelToken, ChatCommand, ChatModel, ChatRequest, ChatResponse, ClaudeToolType, CompletionContext, ConfirmationData, Host, InlineCompletionModel, MarkdownData, ProgressData, SignalImpl, ToolCallData
1918
from notebook_intelligence.base_chat_participant import BaseChatParticipant
2019
from notebook_intelligence._version import __version__ as NBI_VERSION
2120
import base64
2221
import logging
2322
from claude_agent_sdk import AssistantMessage, PermissionResultAllow, PermissionResultDeny, TextBlock, ToolResultBlock, ToolUseBlock, UserMessage, create_sdk_mcp_server, ClaudeAgentOptions, ClaudeSDKClient, tool
24-
from anthropic.types.text_block import TextBlock as AnthropicTextBlock
2523

26-
from notebook_intelligence.util import ThreadSafeWebSocketConnector, _emit, get_jupyter_root_dir, resolve_claude_cli_path, safe_jupyter_path, terminate_process_tree
24+
from notebook_intelligence.util import ThreadSafeWebSocketConnector, _emit, get_jupyter_root_dir, import_litellm, resolve_claude_cli_path, safe_jupyter_path, terminate_process_tree
25+
26+
if TYPE_CHECKING:
27+
from anthropic import Anthropic
2728

2829
log = logging.getLogger(__name__)
2930

@@ -345,7 +346,7 @@ def get_claude_models() -> list[dict]:
345346
def _get_context_window(model_id: str) -> int:
346347
"""Get context window size for a model using litellm's model database."""
347348
try:
348-
import litellm
349+
litellm = import_litellm()
349350
info = litellm.get_model_info(model_id)
350351
return info.get("max_input_tokens", 200000)
351352
except Exception:
@@ -364,8 +365,9 @@ def _normalize_anthropic_credential(value: Any) -> str | None:
364365
return value.strip() or None
365366

366367

367-
def _create_anthropic_client(api_key: str = None, base_url: str = None) -> Anthropic:
368+
def _create_anthropic_client(api_key: str = None, base_url: str = None) -> "Anthropic":
368369
"""Create an Anthropic client with normalized credentials and default headers."""
370+
from anthropic import Anthropic
369371
api_key = _normalize_anthropic_credential(api_key)
370372
base_url = _normalize_anthropic_credential(base_url)
371373
return Anthropic(
@@ -544,6 +546,8 @@ def inline_completions(self, prefix, suffix, language, filename, context: Comple
544546
if cancel_token.is_cancel_requested:
545547
return ''
546548

549+
from anthropic.types.text_block import TextBlock as AnthropicTextBlock
550+
547551
message = self._client.messages.create(
548552
model=self._model_id,
549553
max_tokens=10000,

notebook_intelligence/llm_providers/litellm_compatible_llm_provider.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
from typing import Any
55
from notebook_intelligence.api import ChatModel, EmbeddingModel, InlineCompletionModel, LLMProvider, CancelToken, ChatResponse, CompletionContext, LLMProviderProperty
6-
import litellm
6+
from notebook_intelligence.util import import_litellm
77

88
DEFAULT_CONTEXT_WINDOW = 4096
99

@@ -37,6 +37,7 @@ def context_window(self) -> int:
3737
return DEFAULT_CONTEXT_WINDOW
3838

3939
def completions(self, messages: list[dict], tools: list[dict] = None, response: ChatResponse = None, cancel_token: CancelToken = None, options: dict = {}) -> Any:
40+
litellm = import_litellm()
4041
stream = response is not None
4142
model_id = self.get_property("model_id").value
4243
base_url = self.get_property("base_url").value
@@ -111,6 +112,7 @@ def context_window(self) -> int:
111112
return DEFAULT_CONTEXT_WINDOW
112113

113114
def inline_completions(self, prefix, suffix, language, filename, context: CompletionContext, cancel_token: CancelToken) -> str:
115+
litellm = import_litellm()
114116
model_id = self.get_property("model_id").value
115117
base_url = self.get_property("base_url").value
116118
api_key_prop = self.get_property("api_key")

notebook_intelligence/llm_providers/ollama_llm_provider.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import json
44
from typing import Any
55
from notebook_intelligence.api import ChatModel, EmbeddingModel, InlineCompletionModel, LLMProvider, CancelToken, ChatResponse, CompletionContext
6-
import ollama
76
import logging
87

98
from notebook_intelligence.util import extract_llm_generated_code
@@ -37,6 +36,7 @@ def context_window(self) -> int:
3736
return self._context_window
3837

3938
def completions(self, messages: list[dict], tools: list[dict] = None, response: ChatResponse = None, cancel_token: CancelToken = None, options: dict = {}) -> Any:
39+
import ollama
4040
stream = response is not None
4141
completion_args = {
4242
"model": self._model_id,
@@ -102,6 +102,7 @@ def context_window(self) -> int:
102102
return self._context_window
103103

104104
def inline_completions(self, prefix, suffix, language, filename, context: CompletionContext, cancel_token: CancelToken) -> str:
105+
import ollama
105106
has_suffix = suffix.strip() != ""
106107
if has_suffix:
107108
prompt = self._prompt_template.format(prefix=prefix, suffix=suffix.strip())
@@ -171,6 +172,7 @@ def embedding_models(self) -> list[EmbeddingModel]:
171172

172173
def update_chat_model_list(self):
173174
try:
175+
import ollama
174176
response = ollama.list()
175177
models = response.models
176178
self._chat_models = []

notebook_intelligence/llm_providers/openai_compatible_llm_provider.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import re
66
from typing import Any
77
from notebook_intelligence.api import ChatModel, EmbeddingModel, InlineCompletionModel, LLMProvider, CancelToken, ChatResponse, CompletionContext, LLMProviderProperty
8-
from openai import OpenAI, omit
98

109
INLINE_COMPLETION_SYSTEM_PROMPT = """You are a code completion assistant. Your task is to generate intelligent autocomplete suggestions for the code at the cursor position for given language and active file type. This is not an interactive session, don't ask for clarifying questions, always generate a suggestion. Don't include any explanations for your response, just generate the code. Don't return any thinking or reasoning, just generate the code. You are given a code snippet with a prefix and a suffix. You need to generate a suggestion for the code that fits best in place of <CURSOR/>. You should return only the code that fits best in place of <CURSOR/>. You should provide multiline code if needed. Enclose the code in triple backticks, just return the code in language. You should not return any other text, just the code. DO NOT INCLUDE THE PREFIX OR SUFFIX IN THE RESPONSE. .ipynb files are Jupyter notebook files and for notebook files, you generate suggestions for a cell within the notebook. A cell can be a code cell with code or a markdown cell with markdown text. If the language is markdown, only return markdown text. If you need to install a Python package within a notebook cell code (for .ipynb files), use %pip install <package_name> instead of !pip install <package_name>. Follow the tags very carefully for proper spacing and indentations."""
1110

@@ -55,6 +54,7 @@ def context_window(self) -> int:
5554
return DEFAULT_CONTEXT_WINDOW
5655

5756
def completions(self, messages: list[dict], tools: list[dict] = None, response: ChatResponse = None, cancel_token: CancelToken = None, options: dict = {}) -> Any:
57+
from openai import OpenAI, omit
5858
stream = response is not None
5959
model_id = self.get_property("model_id").value
6060
base_url_prop = self.get_property("base_url")
@@ -152,6 +152,8 @@ def inline_completions(self, prefix, suffix, language, filename, context: Comple
152152
if cancel_token.is_cancel_requested:
153153
return ''
154154

155+
from openai import OpenAI
156+
155157
model_id = self.get_property("model_id").value
156158
base_url_prop = self.get_property("base_url")
157159
base_url = base_url_prop.value if base_url_prop is not None else None

notebook_intelligence/util.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@
2626
_jupyter_root_dir: str = None
2727
_enabled_tools: Set[str] = None
2828

29+
def import_litellm():
30+
"""Import litellm on first use instead of at server-extension import.
31+
32+
litellm is over a second of import time (the largest single chunk of
33+
NBI's startup) and, by default, fetches its model-cost map over HTTP at
34+
import, which can stall on proxied networks. Defaulting
35+
LITELLM_LOCAL_MODEL_COST_MAP to true makes it read the map bundled with
36+
the installed litellm instead; set the env var to false to restore the
37+
remote fetch.
38+
"""
39+
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "true")
40+
import litellm
41+
return litellm
42+
2943
def set_jupyter_root_dir(root_dir: str):
3044
global _jupyter_root_dir
3145
_jupyter_root_dir = root_dir

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ test = ["pytest>=7.0,<9", "pytest-timeout>=2.3.0", "psutil", "pyyaml"]
9292
# job. Other tests in the suite run in milliseconds, so 30s is
9393
# generous headroom rather than a target.
9494
timeout = 30
95+
# Make `from tests.conftest import ...` resolvable on its own. Without
96+
# this, those imports only worked because the eager `import litellm`
97+
# pulled in litellm/proxy/proxy_cli.py, which calls
98+
# sys.path.append(os.getcwd()) at import time; deferring the litellm
99+
# import (#370) removed that accidental path entry and broke collection
100+
# under a non-editable install (editable installs already put the repo
101+
# root on sys.path, which is why local runs never noticed).
102+
pythonpath = ["."]
95103

96104
[tool.hatch.version]
97105
source = "nodejs"

tests/test_claude_models.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def _make_mock_model(self, model_id, display_name):
7878
return m
7979

8080
@patch("notebook_intelligence.claude._get_context_window", return_value=200000)
81-
@patch("notebook_intelligence.claude.Anthropic")
81+
@patch("anthropic.Anthropic")
8282
def test_fetches_and_caches_models(self, mock_anthropic_cls, mock_ctx_window):
8383
from notebook_intelligence.claude import fetch_claude_models, get_claude_models
8484

@@ -98,7 +98,7 @@ def test_fetches_and_caches_models(self, mock_anthropic_cls, mock_ctx_window):
9898
assert len(get_claude_models()) == 2
9999

100100
@patch("notebook_intelligence.claude._get_context_window", return_value=150000)
101-
@patch("notebook_intelligence.claude.Anthropic")
101+
@patch("anthropic.Anthropic")
102102
def test_uses_litellm_context_window(self, mock_anthropic_cls, mock_ctx_window):
103103
from notebook_intelligence.claude import fetch_claude_models
104104

@@ -112,7 +112,7 @@ def test_uses_litellm_context_window(self, mock_anthropic_cls, mock_ctx_window):
112112
mock_ctx_window.assert_called_once_with("claude-sonnet-4-6")
113113

114114
@patch("notebook_intelligence.claude._get_context_window", return_value=200000)
115-
@patch("notebook_intelligence.claude.Anthropic")
115+
@patch("anthropic.Anthropic")
116116
def test_cache_is_mutated_in_place(self, mock_anthropic_cls, mock_ctx_window):
117117
"""Verify the cache list is mutated, not replaced, so importers keep a valid reference."""
118118
from notebook_intelligence.claude import fetch_claude_models, _claude_models_cache
@@ -129,7 +129,7 @@ def test_cache_is_mutated_in_place(self, mock_anthropic_cls, mock_ctx_window):
129129
assert len(original_list) == 1
130130

131131
@patch("notebook_intelligence.claude._get_context_window", return_value=200000)
132-
@patch("notebook_intelligence.claude.Anthropic")
132+
@patch("anthropic.Anthropic")
133133
def test_clears_old_cache_on_refresh(self, mock_anthropic_cls, mock_ctx_window):
134134
from notebook_intelligence.claude import fetch_claude_models, get_claude_models, _claude_models_cache
135135

@@ -147,7 +147,7 @@ def test_clears_old_cache_on_refresh(self, mock_anthropic_cls, mock_ctx_window):
147147
assert len(models) == 1
148148
assert models[0]["id"] == "new-model"
149149

150-
@patch("notebook_intelligence.claude.Anthropic")
150+
@patch("anthropic.Anthropic")
151151
def test_returns_existing_cache_on_api_failure(self, mock_anthropic_cls):
152152
from notebook_intelligence.claude import fetch_claude_models, get_claude_models, _claude_models_cache
153153

@@ -162,7 +162,7 @@ def test_returns_existing_cache_on_api_failure(self, mock_anthropic_cls):
162162
assert result[0]["id"] == "cached-model"
163163

164164
@patch("notebook_intelligence.claude._get_context_window", return_value=200000)
165-
@patch("notebook_intelligence.claude.Anthropic")
165+
@patch("anthropic.Anthropic")
166166
def test_empty_api_key_passed_as_none(self, mock_anthropic_cls, mock_ctx_window):
167167
from notebook_intelligence.claude import fetch_claude_models
168168

0 commit comments

Comments
 (0)