Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions beaker-vue/src/components/cell/BaseQueryCell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export const useBaseQueryCell = (props) => {
});
msg.cell = cell.value;
}
// Tag auth-failure messages with the originating cell so the model
// configuration dialog can re-run this query once credentials are
// provided (otherwise the user's prompt is silently dropped).
else if (msg.header?.msg_type === 'llm_auth_failure') {
msg.cell = cell.value;
}
return true;
}
)
});
Expand Down
9 changes: 7 additions & 2 deletions beaker-vue/src/components/misc/ProviderSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,14 @@ onBeforeMount(() => {
}
}

#config-panel-provider {
#provider-select-providers {
grid-area: provider;
background-color: blue;
// Allow the form to scroll within its grid cell instead of overflowing
// down onto the dialog buttons row. `min-height: 0` is required for a
// grid item to shrink below its content size and enable scrolling.
min-height: 0;
overflow-y: auto;
padding: 0 0.5rem 0.5rem 1rem;
}

.p-listbox-item {
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ classifiers = [
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = [
"archytas>=2.0.3",
"archytas>=2.0.4",
"jupyterlab~=4.0",
"jupyterlab-server>=2.22.1,<3",
"requests>=2.24,<3",
Expand Down
19 changes: 15 additions & 4 deletions src/beaker_notebook/app/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,16 @@ def map_type(type_obj: type):
if type_args:
if isinstance(type_obj, UnionType):
type_def["type_str"] = repr(type_obj)
elif issubclass(type_origin, Choice):
elif isinstance(type_origin, type) and issubclass(type_origin, Choice):
source = get_args(type_args[0])[0]
type_def["type_str"] = f"{type_origin.__name__}['{source}']"
type_def["choice_source"] = source
elif isinstance(type_origin, type):
type_def["type_str"] = f"{type_origin.__name__}[{', '.join(getattr(arg, '__name__', repr(arg)) for arg in type_args)}]"
else:
type_def["type_str"] = f"{type_origin.__name__}[{', '.join(arg.__name__ for arg in type_args)}]"
# Non-class origins (e.g. typing.Literal[...]) have no __name__
# and aren't valid issubclass() args; represent them directly.
type_def["type_str"] = repr(type_obj)
if type_origin:
type_def["type_origin"] = ConfigController.map_type(type_origin)
if type_args:
Expand All @@ -185,7 +189,8 @@ def map_type(type_obj: type):
type_def["default_value"] = default_value
except TypeError:
pass
except:
except Exception:
logger.exception("Failed to build config schema for type %r; falling back to repr", type_obj)
type_def["type_str"] = repr(type_obj)
return type_def

Expand All @@ -202,7 +207,13 @@ def jsonify_dataclass_schema(obj):
description = metadata.pop("description", None)
option_func = metadata.pop("options", None)
if option_func and callable(option_func):
metadata["options"] = option_func()
try:
metadata["options"] = option_func()
except Exception:
# A failing options provider must not blank out the field (and,
# for nested schemas, the entire config form). Degrade gracefully.
logger.exception("Failed to resolve options for config field %r", field_name)
metadata["options"] = None
field_result = {
"name": field.name,
"description": description,
Expand Down
4 changes: 3 additions & 1 deletion src/beaker_notebook/kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,9 @@ async def set_agent_model(self, message):
reset_config()
model = config.get_model()
if model:
self.context.agent.model = model
# set_model updates both the agent's model and the chat history's
# (the UI reads model metadata + token budgets from the latter).
self.context.agent.set_model(model)
await self.send_set_chat_history(message.header)

@message_handler
Expand Down
28 changes: 24 additions & 4 deletions src/beaker_notebook/lib/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import binascii
import functools
import importlib.resources
import dotenv
import importlib
Expand All @@ -18,16 +19,31 @@
logger = logging.getLogger(__name__)


def get_providers() -> dict[str, str]:
@functools.lru_cache(maxsize=1)
def _discover_provider_import_paths() -> tuple[tuple[str, str], ...]:
"""Discover available archytas model provider classes as (name, import_path).

Cached because the set of model modules is fixed for the life of a process,
and a failed import is *not* memoized by ``importlib`` -- so without caching
every call re-attempts (and re-logs) any broken module. Returns an immutable
tuple so the cache can't be mutated by callers.
"""
import archytas.models
from archytas.models.base import BaseArchytasModel
base_class = BaseArchytasModel
result = {}
result: dict[str, str] = {}
for resource in importlib.resources.files(archytas.models).iterdir():
if resource.is_file() and resource.name.endswith('.py'):
name = resource.name.split('.', 1)[0]
mod_name = f'archytas.models.{name}'
mod = importlib.import_module(mod_name, 'archytas.models')
try:
mod = importlib.import_module(mod_name, 'archytas.models')
except Exception as err:
# A single model module that fails to import (e.g. an upstream
# dependency mismatch) must not take down provider discovery and,
# with it, the entire config/provider-selection UI. Skip it.
logger.warning("Skipping archytas model module %r: failed to import (%s)", mod_name, err)
continue
items = inspect.getmembers(
mod,
lambda item:
Expand All @@ -36,7 +52,11 @@ def get_providers() -> dict[str, str]:
item is not base_class
)
result.update({item[0]: f"{item[1].__module__}.{item[1].__name__}" for item in items})
return result
return tuple(result.items())


def get_providers() -> dict[str, str]:
return dict(_discover_provider_import_paths())


CONFIG_FILE_SEARCH_LOCATIONS = [ # (path, filename, check_parent_paths, default)
Expand Down
39 changes: 39 additions & 0 deletions tests/test_get_providers_resilience.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Regression test for provider discovery tolerating broken model modules.

A single archytas model module that fails to import (e.g. an upstream
dependency mismatch) must not take down provider discovery and, with it, the
entire config/provider-selection UI.
"""
import importlib

import beaker_notebook.lib.config as config_mod


def test_get_providers_tolerates_failing_imports(monkeypatch):
# Force every per-module import to fail; discovery must degrade to an empty
# mapping rather than raising.
config_mod._discover_provider_import_paths.cache_clear()

def always_fail(name, package=None):
raise ImportError(f"simulated failure importing {name}")

monkeypatch.setattr(importlib, "import_module", always_fail)
try:
providers = config_mod.get_providers()
finally:
config_mod._discover_provider_import_paths.cache_clear()

assert providers == {}


def test_get_providers_returns_fresh_mapping():
# The cached discovery is immutable; callers get a fresh dict each call so
# mutating the result can't corrupt the cache.
config_mod._discover_provider_import_paths.cache_clear()
try:
first = config_mod.get_providers()
first["__sentinel__"] = "x"
second = config_mod.get_providers()
assert "__sentinel__" not in second
finally:
config_mod._discover_provider_import_paths.cache_clear()