refactor(studio): align catalog route behavior

parent 842f8eb4
...@@ -259,9 +259,9 @@ async def dashboard_studio(request: Request): ...@@ -259,9 +259,9 @@ async def dashboard_studio(request: Request):
@router.get("/dashboard/studio/catalog") @router.get("/dashboard/studio/catalog")
async def dashboard_studio_catalog(request: Request): async def dashboard_studio_catalog(request: Request):
"""Return Studio catalog for the current dashboard principal.""" """Return Studio catalog for the current dashboard principal."""
auth_check = require_dashboard_auth(request) auth_check = require_api_auth(request)
if auth_check: if auth_check:
return JSONResponse({"entries": [], "error": "unauthorized"}, status_code=401) return auth_check
current_user_id = request.session.get("user_id") current_user_id = request.session.get("user_id")
scope = "admin" if request.session.get("role") == "admin" else "user" scope = "admin" if request.session.get("role") == "admin" else "user"
......
...@@ -215,6 +215,34 @@ def build_catalog_entry( ...@@ -215,6 +215,34 @@ def build_catalog_entry(
} }
def _build_named_catalog_entry(
*,
prefix: str,
scope: str,
owner_id: Optional[int],
target_id: str,
label: str,
description: Optional[str],
capabilities: Optional[Iterable[str]],
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
entry = build_catalog_entry(
scope=scope,
owner_id=owner_id,
kind=prefix,
source_id=prefix,
target_id=target_id,
label=label,
description=description,
capabilities=capabilities,
availability_state="ready",
availability_reason=None,
metadata=metadata,
)
entry["id"] = f"{prefix}/{prefix}/{target_id}"
return entry
def _coerce_model_dict(model: Any) -> Dict[str, Any]: def _coerce_model_dict(model: Any) -> Dict[str, Any]:
if isinstance(model, dict): if isinstance(model, dict):
return model return model
...@@ -243,10 +271,12 @@ def _provider_models_from_config(provider_config: Any) -> List[Dict[str, Any]]: ...@@ -243,10 +271,12 @@ def _provider_models_from_config(provider_config: Any) -> List[Dict[str, Any]]:
return [_coerce_model_dict(model) for model in (models or [])] return [_coerce_model_dict(model) for model in (models or [])]
def _load_global_providers_from_disk() -> Dict[str, Dict[str, Any]]: def _load_global_providers_from_source() -> Dict[str, Dict[str, Any]]:
config_path = Path.home() / ".aisbf" / "providers.json" config_path = Path.home() / ".aisbf" / "providers.json"
if not config_path.exists(): if not config_path.exists():
return {} config_path = Path(__file__).parent.parent / "config" / "providers.json"
if not config_path.exists():
return {}
with open(config_path) as handle: with open(config_path) as handle:
payload = json.load(handle) payload = json.load(handle)
...@@ -314,23 +344,19 @@ def _build_rotation_entries(scope: str, owner_id: Optional[int], rotations: Dict ...@@ -314,23 +344,19 @@ def _build_rotation_entries(scope: str, owner_id: Optional[int], rotations: Dict
for rotation_id, rotation_config in rotations.items(): for rotation_id, rotation_config in rotations.items():
config_data = rotation_config if isinstance(rotation_config, dict) else rotation_config.model_dump() config_data = rotation_config if isinstance(rotation_config, dict) else rotation_config.model_dump()
entries.append( entries.append(
{ _build_named_catalog_entry(
"id": f"rotation/{rotation_id}", prefix="rotation",
"kind": "rotation", scope=scope,
"owner_scope": scope, owner_id=owner_id,
"owner_id": owner_id, target_id=rotation_id,
"source_id": rotation_id, label=config_data.get("model_name") or rotation_id,
"target_id": rotation_id, description=config_data.get("description"),
"label": config_data.get("model_name") or rotation_id, capabilities=config_data.get("capabilities"),
"description": config_data.get("description"), metadata={
"capabilities": normalize_capabilities(config_data.get("capabilities")),
"availability_state": "ready",
"availability_reason": None,
"metadata": {
"provider_count": len(config_data.get("providers") or []), "provider_count": len(config_data.get("providers") or []),
"context_length": config_data.get("context_length"), "context_length": config_data.get("context_length"),
}, },
} )
) )
return entries return entries
...@@ -341,24 +367,20 @@ def _build_autoselect_entries(scope: str, owner_id: Optional[int], autoselects: ...@@ -341,24 +367,20 @@ def _build_autoselect_entries(scope: str, owner_id: Optional[int], autoselects:
config_data = autoselect_config if isinstance(autoselect_config, dict) else autoselect_config.model_dump() config_data = autoselect_config if isinstance(autoselect_config, dict) else autoselect_config.model_dump()
available_models = config_data.get("available_models") or [] available_models = config_data.get("available_models") or []
entries.append( entries.append(
{ _build_named_catalog_entry(
"id": f"autoselect/{autoselect_id}", prefix="autoselect",
"kind": "autoselect", scope=scope,
"owner_scope": scope, owner_id=owner_id,
"owner_id": owner_id, target_id=autoselect_id,
"source_id": autoselect_id, label=config_data.get("model_name") or autoselect_id,
"target_id": autoselect_id, description=config_data.get("description"),
"label": config_data.get("model_name") or autoselect_id, capabilities=config_data.get("capabilities"),
"description": config_data.get("description"), metadata={
"capabilities": normalize_capabilities(config_data.get("capabilities")),
"availability_state": "ready",
"availability_reason": None,
"metadata": {
"available_model_count": len(available_models), "available_model_count": len(available_models),
"fallback": config_data.get("fallback"), "fallback": config_data.get("fallback"),
"selection_model": config_data.get("selection_model"), "selection_model": config_data.get("selection_model"),
}, },
} )
) )
return entries return entries
...@@ -378,7 +400,7 @@ def build_studio_catalog( ...@@ -378,7 +400,7 @@ def build_studio_catalog(
rotations = {row["rotation_id"]: row.get("config", {}) for row in rotation_rows} rotations = {row["rotation_id"]: row.get("config", {}) for row in rotation_rows}
autoselects = {row["autoselect_id"]: row.get("config", {}) for row in autoselect_rows} autoselects = {row["autoselect_id"]: row.get("config", {}) for row in autoselect_rows}
else: else:
providers = getattr(config, "providers", None) or _load_global_providers_from_disk() providers = getattr(config, "providers", None) or _load_global_providers_from_source()
rotations = getattr(config, "rotations", None) or {} rotations = getattr(config, "rotations", None) or {}
autoselects = getattr(config, "autoselect", None) or {} autoselects = getattr(config, "autoselect", None) or {}
......
...@@ -107,6 +107,15 @@ def test_dashboard_studio_catalog_returns_global_resources_for_admin(monkeypatch ...@@ -107,6 +107,15 @@ def test_dashboard_studio_catalog_returns_global_resources_for_admin(monkeypatch
} }
def test_dashboard_studio_catalog_uses_api_auth_json_when_logged_out():
client = TestClient(app)
response = client.get("/dashboard/studio/catalog")
assert response.status_code == 401
assert response.json() == {"error": "Authentication required"}
def test_dashboard_studio_catalog_returns_user_resources_for_user(monkeypatch): def test_dashboard_studio_catalog_returns_user_resources_for_user(monkeypatch):
client = TestClient(app) client = TestClient(app)
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
...@@ -222,6 +231,58 @@ def test_build_studio_catalog_uses_global_config_for_admin_scope(): ...@@ -222,6 +231,58 @@ def test_build_studio_catalog_uses_global_config_for_admin_scope():
assert provider_entry["metadata"]["context_length"] == 128000 assert provider_entry["metadata"]["context_length"] == 128000
def test_build_studio_catalog_falls_back_to_dashboard_global_provider_source(monkeypatch):
monkeypatch.setattr(
"aisbf.studio._load_global_providers_from_source",
lambda: {
"fallback-openai": {
"type": "openai",
"models": [{"name": "gpt-4.1-mini", "description": "Fallback model", "capabilities": ["chat"]}],
}
},
)
catalog = build_studio_catalog(scope="admin", owner_id=None, config=None)
assert catalog["scope"] == "admin"
provider_entry = next(entry for entry in catalog["entries"] if entry["kind"] == "provider_model")
assert provider_entry["id"] == "provider/fallback-openai/gpt-4.1-mini"
assert provider_entry["owner_scope"] == "admin"
def test_build_studio_catalog_reuses_catalog_entry_contract_for_non_provider_resources():
class ConfigStub:
providers = {}
rotations = {
"team-default": {
"model_name": "Team default",
"providers": [{"provider": "openai", "model": "gpt-4o"}],
"capabilities": ["chat"],
}
}
autoselect = {
"writer": {
"model_name": "Writer",
"description": "General writing",
"fallback": "openai/gpt-4o",
"selection_model": "internal",
"available_models": [{"model_id": "openai/gpt-4o", "description": "Primary"}],
"capabilities": ["chat"],
}
}
catalog = build_studio_catalog(scope="admin", owner_id=None, config=ConfigStub())
rotation_entry = next(entry for entry in catalog["entries"] if entry["kind"] == "rotation")
autoselect_entry = next(entry for entry in catalog["entries"] if entry["kind"] == "autoselect")
assert rotation_entry["source_id"] == "rotation"
assert rotation_entry["target_id"] == "team-default"
assert rotation_entry["id"] == "rotation/rotation/team-default"
assert autoselect_entry["source_id"] == "autoselect"
assert autoselect_entry["target_id"] == "writer"
assert autoselect_entry["id"] == "autoselect/autoselect/writer"
def test_build_studio_catalog_uses_user_owned_resources_for_user_scope(): def test_build_studio_catalog_uses_user_owned_resources_for_user_scope():
class DbStub: class DbStub:
def get_user_providers(self, user_id): def get_user_providers(self, user_id):
...@@ -261,8 +322,8 @@ def test_build_studio_catalog_uses_user_owned_resources_for_user_scope(): ...@@ -261,8 +322,8 @@ def test_build_studio_catalog_uses_user_owned_resources_for_user_scope():
assert all(entry["owner_scope"] == "user" for entry in catalog["entries"]) assert all(entry["owner_scope"] == "user" for entry in catalog["entries"])
assert {entry["id"] for entry in catalog["entries"]} == { assert {entry["id"] for entry in catalog["entries"]} == {
"provider/local-openai/gpt-4o-mini", "provider/local-openai/gpt-4o-mini",
"rotation/my-rotation", "rotation/rotation/my-rotation",
"autoselect/my-autoselect", "autoselect/autoselect/my-autoselect",
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment