Skip to content

Commit afabc31

Browse files
Merge branch 'jupyterlab:main' into copilot
2 parents 9520f03 + 06338f1 commit afabc31

File tree

10 files changed

+372
-17
lines changed

10 files changed

+372
-17
lines changed
39.8 KB
Loading

docs/source/users/openrouter.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ Jupyter AI's settings page with the OpenRouter provider selected is shown below:
2424

2525
Type in the model name and the API base URL corresponding to the model you wish to use. For Deepseek, you should use `https://api.deepseek.com` as the API base URL, and use `deepseek-chat` as the local model ID.
2626

27+
If you have an OpenRouter account and wish to use their API and URL, it is also possible using the OpenRouter provider in Jupyter AI, as shown here:
28+
29+
<img src="../_static/openrouter-model-setup-2.png"
30+
width="75%"
31+
alt='Screenshot of the tab in Jupyter AI where OpenRouter model access is selected with its own API and URL.'
32+
class="screenshot" />
33+
2734
If you are using OpenRouter for the first time it will also require entering the `OPENROUTER_API_KEY`. If you have used OpenRouter before with a different model provider, you will need to update the API key. After doing this, click "Save Changes" at the bottom to save your settings.
2835

2936
You should now be able to use Deepseek! An example of usage is shown next:

packages/jupyter-ai-magics/jupyter_ai_magics/partner_providers/openai.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ class ChatOpenAIProvider(BaseProvider, ChatOpenAI):
4848
"gpt-4o-2024-11-20",
4949
"gpt-4o-mini",
5050
"chatgpt-4o-latest",
51+
"gpt-4.1",
52+
"gpt-4.1-mini",
53+
"gpt-4.1-nano",
54+
"o1",
55+
"o3-mini",
56+
"o4-mini",
5157
]
5258
model_id_key = "model_name"
5359
pypi_package_deps = ["langchain_openai"]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import asyncio
2+
import getpass
3+
4+
from jupyter_server.auth.identity import IdentityProvider, User
5+
6+
7+
def create_initials(username):
8+
"""Creates initials combining first 2 consonants"""
9+
10+
username = username.lower()
11+
12+
# Default: return first two unique consonants
13+
consonants = [c for c in username if c in "bcdfghjklmnpqrstvwxyz"]
14+
if len(consonants) >= 2:
15+
return (consonants[0] + consonants[1]).upper()
16+
17+
# Fallback: first two characters
18+
return username[:2].upper()
19+
20+
21+
class LocalIdentityProvider(IdentityProvider):
22+
"""IdentityProvider that determines username from system user."""
23+
24+
def get_user(self, handler):
25+
try:
26+
username = getpass.getuser()
27+
user = User(
28+
username=username,
29+
name=username,
30+
initials=create_initials(username),
31+
color=None,
32+
)
33+
return user
34+
except OSError as e:
35+
self.log.debug(
36+
"Could not determine username from system. Falling back to anonymous"
37+
f"user."
38+
)
39+
return self._get_user(handler)

packages/jupyter-ai/jupyter_ai/config_manager.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import json
22
import logging
33
import os
4-
import shutil
54
import time
65
from copy import deepcopy
76
from typing import List, Optional, Type, Union
87

9-
from deepmerge import always_merger as Merger
8+
from deepmerge import Merger, always_merger
109
from jsonschema import Draft202012Validator as Validator
1110
from jupyter_ai.models import DescribeConfigResponse, GlobalConfig, UpdateConfigRequest
1211
from jupyter_ai_magics import JupyternautPersona, Persona
@@ -178,11 +177,23 @@ def _init_config_schema(self):
178177
with open(OUR_SCHEMA_PATH, encoding="utf-8") as f:
179178
default_schema = json.load(f)
180179

180+
# Create a custom `deepmerge.Merger` object to merge lists using the
181+
# 'append_unique' strategy.
182+
#
183+
# This stops type union declarations like `["string", "null"]` from
184+
# growing into `["string", "null", "string", "null"]` on restart.
185+
# This fixes issue #1320.
186+
merger = Merger(
187+
[(list, ["append_unique"]), (dict, ["merge"]), (set, ["union"])],
188+
["override"],
189+
["override"],
190+
)
191+
181192
# merge existing_schema into default_schema
182193
# specifying existing_schema as the second argument ensures that
183194
# existing_schema always overrides existing keys in default_schema, i.e.
184195
# this call only adds new keys in default_schema.
185-
schema = Merger.merge(default_schema, existing_schema)
196+
schema = merger.merge(default_schema, existing_schema)
186197
with open(self.schema_path, encoding="utf-8", mode="w") as f:
187198
json.dump(schema, f, indent=self.indentation_depth)
188199

@@ -194,15 +205,17 @@ def _init_validator(self) -> None:
194205

195206
def _init_config(self):
196207
default_config = self._init_defaults()
197-
if os.path.exists(self.config_path):
208+
if os.path.exists(self.config_path) and os.stat(self.config_path).st_size != 0:
198209
self._process_existing_config(default_config)
199210
else:
200211
self._create_default_config(default_config)
201212

202213
def _process_existing_config(self, default_config):
203214
with open(self.config_path, encoding="utf-8") as f:
204215
existing_config = json.loads(f.read())
205-
merged_config = Merger.merge(
216+
if "embeddings_fields" not in existing_config:
217+
existing_config["embeddings_fields"] = {}
218+
merged_config = always_merger.merge(
206219
default_config,
207220
{k: v for k, v in existing_config.items() if v is not None},
208221
)
@@ -305,6 +318,8 @@ def _read_config(self) -> GlobalConfig:
305318
with open(self.config_path, encoding="utf-8") as f:
306319
self._last_read = time.time_ns()
307320
raw_config = json.loads(f.read())
321+
if "embeddings_fields" not in raw_config:
322+
raw_config["embeddings_fields"] = {}
308323
config = GlobalConfig(**raw_config)
309324
self._validate_config(config)
310325
return config
@@ -481,7 +496,7 @@ def update_config(self, config_update: UpdateConfigRequest): # type:ignore
481496
raise KeyEmptyError("API key value cannot be empty.")
482497

483498
config_dict = self._read_config().model_dump()
484-
Merger.merge(config_dict, config_update.model_dump(exclude_unset=True))
499+
always_merger.merge(config_dict, config_update.model_dump(exclude_unset=True))
485500
self._write_config(GlobalConfig(**config_dict))
486501

487502
# this cannot be a property, as the parent Configurable already defines the

packages/jupyter-ai/jupyter_ai/tests/test_config_manager.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import logging
33
import os
4+
from pathlib import Path
45
from unittest.mock import mock_open, patch
56

67
import pytest
@@ -513,8 +514,8 @@ def test_config_manager_does_not_write_to_defaults(
513514
def test_config_manager_updates_schema(jp_data_dir, common_cm_kwargs):
514515
"""
515516
Asserts that the ConfigManager adds new keys to the user's config schema
516-
which are present in Jupyter AI's schema on init. Asserts that #1291 does
517-
not occur again in the future.
517+
which are present in Jupyter AI's schema on init. Asserts that the main
518+
issue reported in #1291 does not occur again in the future.
518519
"""
519520
schema_path = str(jp_data_dir / "config_schema.json")
520521
with open(schema_path, "w") as file:
@@ -547,3 +548,17 @@ def test_config_manager_updates_schema(jp_data_dir, common_cm_kwargs):
547548
assert "fields" in new_schema["properties"]
548549
assert "embeddings_fields" in new_schema["properties"]
549550
assert "completions_fields" in new_schema["properties"]
551+
552+
553+
def test_config_manager_handles_empty_touched_file(common_cm_kwargs):
554+
"""
555+
Asserts that ConfigManager does not fail at runtime if `config.json` is a
556+
"touched file", a completely empty file with 0 bytes. This may happen if a
557+
user / build system runs `touch config.json` by accident.
558+
559+
Asserts that the second issue reported in #1291 does not occur again in the
560+
future.
561+
"""
562+
config_path = common_cm_kwargs["config_path"]
563+
Path(config_path).touch()
564+
ConfigManager(**common_cm_kwargs)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import logging
2+
from unittest.mock import Mock, patch
3+
4+
import pytest
5+
from jupyter_ai.auth.identity import LocalIdentityProvider, create_initials
6+
from jupyter_server.auth.identity import User
7+
8+
9+
@pytest.fixture
10+
def log():
11+
log = logging.getLogger()
12+
log.addHandler(logging.NullHandler())
13+
return log
14+
15+
16+
@pytest.fixture
17+
def handler():
18+
return Mock()
19+
20+
21+
@patch("getpass.getuser")
22+
def test_get_user_successful(getuser, log, handler):
23+
24+
getuser.return_value = "localuser"
25+
provider = LocalIdentityProvider(log=log)
26+
27+
user = provider.get_user(handler)
28+
29+
assert isinstance(user, User)
30+
assert user.username == "localuser"
31+
assert user.name == "localuser"
32+
assert user.initials == "LC"
33+
assert user.color is None
34+
35+
36+
@patch("getpass.getuser")
37+
@pytest.mark.asyncio
38+
async def test_get_user_with_error(getuser, log, handler):
39+
40+
getuser.return_value = "localuser"
41+
getuser.side_effect = OSError("Could not get username")
42+
handler._jupyter_current_user = User(username="jupyteruser")
43+
44+
provider = LocalIdentityProvider(log=log)
45+
46+
user = provider.get_user(handler)
47+
user = await user
48+
49+
assert isinstance(user, User)
50+
assert user.username == "jupyteruser"
51+
52+
53+
@pytest.mark.parametrize(
54+
"username,expected_initials",
55+
[
56+
("johndoe", "JH"),
57+
("alice", "LC"),
58+
("xy", "XY"),
59+
("a", "A"),
60+
("SARAH", "SR"),
61+
("john-smith", "JH"),
62+
("john123", "JH"),
63+
("", ""),
64+
],
65+
)
66+
def test_create_initials(username, expected_initials):
67+
"""Test various initials generation scenarios."""
68+
assert create_initials(username) == expected_initials.upper()

packages/jupyter-ai/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"dependencies": {
6262
"@emotion/react": "^11.10.5",
6363
"@emotion/styled": "^11.10.5",
64+
"@jupyter-notebook/application": "^7.2.0",
6465
"@jupyter/chat": "^0.8.1",
6566
"@jupyterlab/application": "^4.2.0",
6667
"@jupyterlab/apputils": "^4.2.0",
@@ -75,6 +76,7 @@
7576
"@jupyterlab/services": "^7.2.0",
7677
"@jupyterlab/settingregistry": "^4.2.0",
7778
"@jupyterlab/ui-components": "^4.2.0",
79+
"@lumino/widgets": "^2.3.2",
7880
"@mui/icons-material": "^5.11.0",
7981
"@mui/material": "^5.11.0",
8082
"react": "^18.2.0",

packages/jupyter-ai/src/index.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { INotebookShell } from '@jupyter-notebook/application';
12
import {
23
JupyterFrontEnd,
34
JupyterFrontEndPlugin
@@ -11,6 +12,7 @@ import {
1112
} from '@jupyterlab/apputils';
1213
import { IDocumentWidget } from '@jupyterlab/docregistry';
1314
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
15+
import { SingletonLayout, Widget } from '@lumino/widgets';
1416

1517
import { chatCommandPlugins } from './chat-commands';
1618
import { completionPlugin } from './completions';
@@ -35,13 +37,19 @@ const plugin: JupyterFrontEndPlugin<void> = {
3537
id: '@jupyter-ai/core:plugin',
3638
autoStart: true,
3739
requires: [IRenderMimeRegistry],
38-
optional: [ICommandPalette, IThemeManager, IJaiCompletionProvider],
40+
optional: [
41+
ICommandPalette,
42+
IThemeManager,
43+
IJaiCompletionProvider,
44+
INotebookShell
45+
],
3946
activate: async (
4047
app: JupyterFrontEnd,
4148
rmRegistry: IRenderMimeRegistry,
4249
palette: ICommandPalette | null,
4350
themeManager: IThemeManager | null,
44-
completionProvider: IJaiCompletionProvider | null
51+
completionProvider: IJaiCompletionProvider | null,
52+
notebookShell: INotebookShell | null
4553
) => {
4654
const openInlineCompleterSettings = () => {
4755
app.commands.execute('settingeditor:open', {
@@ -50,7 +58,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
5058
};
5159

5260
// Create a AI settings widget.
53-
let aiSettings: MainAreaWidget<ReactWidget>;
61+
let aiSettings: Widget;
5462
let settingsWidget: ReactWidget;
5563
try {
5664
settingsWidget = buildAiSettings(
@@ -67,13 +75,20 @@ const plugin: JupyterFrontEndPlugin<void> = {
6775
app.commands.addCommand(CommandIDs.openAiSettings, {
6876
execute: () => {
6977
if (!aiSettings || aiSettings.isDisposed) {
70-
aiSettings = new MainAreaWidget({ content: settingsWidget });
78+
if (notebookShell) {
79+
aiSettings = new Widget();
80+
const layout = new SingletonLayout();
81+
aiSettings.layout = layout;
82+
layout.widget = settingsWidget;
83+
} else {
84+
aiSettings = new MainAreaWidget({ content: settingsWidget });
85+
}
7186
aiSettings.id = 'jupyter-ai-settings';
7287
aiSettings.title.label = 'AI settings';
7388
aiSettings.title.closable = true;
7489
}
7590
if (!aiSettings.isAttached) {
76-
app?.shell.add(aiSettings, 'main');
91+
app?.shell.add(aiSettings, notebookShell ? 'left' : 'main');
7792
}
7893
app.shell.activateById(aiSettings.id);
7994
},

0 commit comments

Comments
 (0)