feat(studio): persist inferred model capabilities

parent f00d8e85
......@@ -48,6 +48,11 @@ class ProviderModelConfig(BaseModel):
privacy: bool = False # Model can handle privacy-sensitive content
# Response caching control
enable_response_cache: Optional[bool] = None # Enable/disable response caching for this model (None = use provider default)
# Studio capability metadata
studio_capabilities: Optional[List[str]] = None
studio_capability_source: Optional[str] = None
studio_capability_unknown: Optional[bool] = None
studio_capability_notes: Optional[List[str]] = None
class CondensationConfig(BaseModel):
......
......@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from aisbf.database import DatabaseRegistry
from aisbf.database import _hash_password as _db_hash_password
from aisbf import __version__
from aisbf.studio import build_studio_catalog
from aisbf.studio import build_studio_catalog, stamp_inferred_capabilities
from aisbf.app.templates import url_for, get_base_url
from aisbf.app.startup import _reload_global_config, _apply_condense_defaults_provider, _apply_condense_defaults_rotation, _providers_json_path, _rotations_json_path, _autoselect_json_path, _claude_cli_mode
from aisbf.app.middleware import _is_local_client
......@@ -22,6 +22,19 @@ _server_config = None
logger = logging.getLogger(__name__)
def _stamp_provider_models(provider_config: dict) -> dict:
stamped = dict(provider_config)
provider_type = stamped.get("type", "openai")
models = stamped.get("models")
if isinstance(models, list):
stamped["models"] = [
stamp_inferred_capabilities(model, provider_type)
if isinstance(model, dict) else model
for model in models
]
return stamped
def init(config, templates, server_config=None):
global _config, _templates, _server_config
_config = config
......@@ -398,6 +411,8 @@ async def _auto_detect_provider_models(provider_key: str, provider: dict) -> lis
'max_request_tokens': int(context_size) if context_size else 100000,
'context_size': int(context_size) if context_size else 100000
})
detected_models = [stamp_inferred_capabilities(model, provider_type) for model in detected_models]
logger.info(f"Auto-detected {len(detected_models)} models for provider '{provider_key}' from {models_url}")
return detected_models
......@@ -429,6 +444,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
if 'condense_method' in model and model.get('condense_method'):
if 'condense_context' not in model or model.get('condense_context') is None:
model['condense_context'] = 80
providers_data[provider_key] = _stamp_provider_models(provider)
if is_config_admin:
# Config admin: save to JSON files
......@@ -1452,6 +1468,7 @@ async def api_provider_save(request: Request):
return JSONResponse({"success": False, "error": "provider_id required"}, status_code=400)
_apply_condense_defaults_provider(provider_config)
provider_config = _stamp_provider_models(provider_config)
if is_config_admin:
config_path = _providers_json_path()
......
......@@ -93,6 +93,27 @@ def normalize_capabilities(values: Optional[Iterable[str]]) -> List[str]:
return _dedupe(normalized)
def stamp_inferred_capabilities(model: Dict[str, Any], provider_type: str) -> Dict[str, Any]:
stamped = dict(model)
capability_result = infer_model_capabilities(
model_name=stamped.get("name") or stamped.get("id") or "",
provider_type=provider_type,
explicit_capabilities=stamped.get("capabilities") or stamped.get("studio_capabilities"),
architecture=stamped.get("architecture"),
provider_metadata=stamped,
)
stamped["capabilities"] = capability_result.capabilities
stamped["studio_capabilities"] = capability_result.capabilities
stamped["studio_capability_source"] = capability_result.source
stamped["studio_capability_unknown"] = capability_result.unknown
if capability_result.notes:
stamped["studio_capability_notes"] = capability_result.notes
elif "studio_capability_notes" in stamped:
stamped.pop("studio_capability_notes", None)
return stamped
def infer_model_capabilities(
model_name: str,
provider_type: str,
......
......@@ -327,6 +327,109 @@ def test_build_studio_catalog_uses_user_owned_resources_for_user_scope():
}
def test_api_provider_save_persists_inferred_studio_metadata_for_manual_models(monkeypatch):
client = TestClient(app)
_login_as_admin(client)
saved_payload = {}
monkeypatch.setattr(dashboard_providers, "_providers_json_path", lambda: Path("/tmp/providers-source.json"))
monkeypatch.setattr(dashboard_providers, "_reload_global_config", lambda: None)
monkeypatch.setattr(dashboard_providers, "open", lambda *args, **kwargs: DummyOpen(saved_payload), raising=False)
response = client.post(
"/dashboard/api/provider",
json={
"provider_id": "openai",
"config": {
"type": "openai",
"models": [
{
"name": "whisper-large-v3",
"architecture": {"input_modalities": ["audio"], "output_modalities": ["text"]},
}
],
},
},
)
assert response.status_code == 200
saved = json.loads(saved_payload["writes"][-1])
model = saved["providers"]["openai"]["models"][0]
assert model["studio_capabilities"] == ["audio_input", "transcription"]
assert model["studio_capability_source"] in {"provider_metadata", "heuristic"}
assert model["studio_capability_unknown"] is False
def test_dashboard_providers_save_persists_inferred_studio_metadata_for_bulk_save(monkeypatch):
client = TestClient(app)
_login_as_admin(client)
saved_payload = {}
monkeypatch.setattr(dashboard_providers, "_reload_global_config", lambda: None)
monkeypatch.setattr(dashboard_providers, "open", lambda *args, **kwargs: DummyOpen(saved_payload), raising=False)
response = client.post(
"/dashboard/providers",
data={
"config": json.dumps(
{
"openai": {
"type": "openai",
"models": [
{
"name": "whisper-large-v3",
"architecture": {"input_modalities": ["audio"], "output_modalities": ["text"]},
}
],
}
}
)
},
)
assert response.status_code == 200
saved = json.loads(saved_payload["writes"][-1])
model = saved["providers"]["openai"]["models"][0]
assert model["studio_capabilities"] == ["audio_input", "transcription"]
assert model["studio_capability_source"] in {"provider_metadata", "heuristic"}
assert model["studio_capability_unknown"] is False
class DummyOpen:
def __init__(self, sink):
self.sink = sink
self.buffer = ""
self.sink.setdefault("writes", [])
def __call__(self, path, mode="r", *args, **kwargs):
self.path = str(path)
self.mode = mode
self.buffer = ""
return self
def __enter__(self):
if "w" in getattr(self, "mode", ""):
self.sink.setdefault("writes", []).append("")
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
if any(token in getattr(self, "path", "") for token in ["providers-source", ".aisbf/providers.json", "routes/dashboard/config/providers.json"]):
return json.dumps({"providers": {}})
writes = self.sink.get("writes") or []
return writes[-1] if writes else json.dumps({"providers": {}})
def write(self, data):
self.buffer += data
self.sink.setdefault("writes", [""])
if not self.sink["writes"]:
self.sink["writes"].append("")
self.sink["writes"][-1] = self.buffer
def _find_session_secret() -> str:
for middleware in app.user_middleware:
kwargs = getattr(middleware, "kwargs", {})
......
......@@ -5,6 +5,7 @@ from aisbf.studio import (
build_catalog_entry,
infer_model_capabilities,
merge_capabilities,
stamp_inferred_capabilities,
)
......@@ -97,3 +98,45 @@ def test_infer_model_capabilities_does_not_fallback_to_chat_for_media_models(mod
)
assert "chat" not in result.capabilities
def test_stamp_inferred_capabilities_persists_inferred_metadata_for_model_dict():
stamped = stamp_inferred_capabilities(
{
"name": "whisper-large-v3",
"architecture": {"input_modalities": ["audio"], "output_modalities": ["text"]},
},
provider_type="openai",
)
assert stamped["studio_capabilities"] == ["audio_input", "transcription"]
assert stamped["studio_capability_source"] in {"provider_metadata", "heuristic"}
assert stamped["studio_capability_unknown"] is False
assert stamped["capabilities"] == ["audio_input", "transcription"]
def test_build_catalog_entry_uses_persisted_studio_capability_metadata_when_available():
entry = build_catalog_entry(
scope="admin",
owner_id=None,
kind="provider_model",
source_id="openai",
target_id="whisper-large-v3",
label="Whisper Large V3",
description=None,
capabilities=["audio_input", "transcription"],
availability_state="ready",
availability_reason=None,
metadata={
"provider_type": "openai",
"capability_source": "heuristic",
"capability_notes": [],
"studio_capabilities": ["audio_input", "transcription"],
"studio_capability_source": "heuristic",
"studio_capability_unknown": False,
},
)
assert entry["metadata"]["studio_capabilities"] == ["audio_input", "transcription"]
assert entry["metadata"]["studio_capability_source"] == "heuristic"
assert entry["metadata"]["studio_capability_unknown"] is False
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