Update to latest revision

parent 926cfaac
......@@ -480,6 +480,12 @@ class CoderAIBroker:
async def send_request(self, provider_id: str, op: str, payload: Dict[str, Any], timeout: float = 300.0, client_id: Optional[str] = None, owner_user_id: Optional[int] = None, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
snapshot = await self.get_session_snapshot(provider_id, client_id)
if (not snapshot or not snapshot.get("connected")) and client_id:
fallback_snapshot = await self.get_session_snapshot(client_id, client_id)
fallback_metadata = (fallback_snapshot or {}).get("metadata") or {}
if fallback_snapshot and fallback_snapshot.get("connected") and fallback_metadata.get("owner_user_id") == owner_user_id:
snapshot = fallback_snapshot
provider_id = snapshot.get("provider_id") or provider_id
if not snapshot or not snapshot.get("connected"):
raise RuntimeError(f"No active CoderAI broker session for provider '{provider_id}'")
if owner_user_id != ((snapshot.get('metadata') or {}).get('owner_user_id')):
......@@ -493,7 +499,7 @@ class CoderAIBroker:
"v": 1,
"op": op,
"request_id": request_id,
"provider_id": provider_id,
"provider_id": snapshot.get("provider_id") or provider_id,
"client_id": snapshot.get("client_id") or client_id,
"payload": payload,
"reply_key": self._reply_key(request_id),
......@@ -509,7 +515,7 @@ class CoderAIBroker:
stream_queue=stream_queue,
request_snapshot={
"session_id": snapshot.get("session_id"),
"provider_id": provider_id,
"provider_id": snapshot.get("provider_id") or provider_id,
"client_id": snapshot.get("client_id") or client_id,
"op": op,
},
......
......@@ -472,6 +472,26 @@ class Config:
raise FileNotFoundError("Could not find configuration files")
def _get_aisbf_config_path(self) -> Path:
"""Resolve the active aisbf.json path consistently across startup and reloads."""
candidates = []
if self._custom_config_dir and self._custom_config_dir.exists():
candidates.append(self._custom_config_dir / 'aisbf.json')
candidates += [
Path.home() / '.aisbf' / 'aisbf.json',
Path.home() / '.local' / 'share' / 'aisbf' / 'aisbf.json',
Path('/usr/local/share/aisbf/aisbf.json'),
Path('/usr/share/aisbf/aisbf.json'),
Path(__file__).parent.parent / 'config' / 'aisbf.json',
]
for candidate in candidates:
if candidate.exists():
return candidate
return candidates[-1]
def _ensure_config_directory(self):
"""Ensure ~/.aisbf/ directory exists and copy default config files if needed"""
config_dir = Path.home() / '.aisbf'
......@@ -930,7 +950,7 @@ class Config:
logger = logging.getLogger(__name__)
logger.info(f"=== Config._load_aisbf_config START ===")
aisbf_path = Path.home() / '.aisbf' / 'aisbf.json'
aisbf_path = self._get_aisbf_config_path()
logger.info(f"Looking for AISBF config in: {aisbf_path}")
if not aisbf_path.exists():
......
......@@ -761,7 +761,9 @@ async def dashboard_settings_save(
'requests_per_hour': max(0, client_rl_general_rph)
}
# Save config
# Save config back to the same resolved path we loaded from
config_path = get_aisbf_config_path()
if not config_path.exists():
config_path = Path.home() / '.aisbf' / 'aisbf.json'
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w') as f:
......
......@@ -19,11 +19,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from pathlib import Path
import json
from typing import Any, Dict, Iterable, List, Optional
from aisbf.app.model_cache import get_provider_models
from aisbf.studio_adapters import effective_studio_adapter, infer_studio_adapter_profile
......@@ -593,8 +595,41 @@ def build_studio_catalog(
rotations = getattr(config, "rotations", None) or {}
autoselects = getattr(config, "autoselect", None) or {}
provider_entries = _build_provider_entries(scope, owner_id, providers)
missing_provider_ids = {
provider_id
for provider_id, provider_config in (providers or {}).items()
if not _provider_models_from_config(provider_config)
}
if missing_provider_ids and config is not None:
for provider_id in missing_provider_ids:
provider_config = providers.get(provider_id)
if provider_config is None:
continue
try:
live_models = asyncio.run(get_provider_models(provider_id, provider_config, config, user_id=owner_id if scope == "user" else None))
except RuntimeError:
live_models = []
except Exception:
live_models = []
if not live_models:
continue
live_model_names = {
(model.get("name") or model.get("model_name") or model.get("id") or "").split("/", 1)[-1]
for model in live_models if isinstance(model, dict)
}
provider_entries = [
entry for entry in provider_entries
if not (entry.get("kind") == "provider_model" and entry.get("source_id") == provider_id and entry.get("target_id") in live_model_names)
]
hydrated_provider = provider_config if isinstance(provider_config, dict) else provider_config.model_dump()
hydrated_provider = dict(hydrated_provider)
hydrated_provider["models"] = live_models
provider_entries.extend(_build_provider_entries(scope, owner_id, {provider_id: hydrated_provider}))
entries = [
*_build_provider_entries(scope, owner_id, providers),
*provider_entries,
*_build_rotation_entries(scope, owner_id, rotations),
*_build_autoselect_entries(scope, owner_id, autoselects),
]
......
......@@ -1609,8 +1609,24 @@ const BLABEL = {text:'LLM',vision:'VLM',image:'IMG',video:'VID',audio:'STT',
function renderSidebar() {
const el = $('model-list');
const activeEl = document.activeElement;
const activeIsBindingSearch = activeEl && activeEl.classList && activeEl.classList.contains('binding-role-search');
const activeValue = activeIsBindingSearch ? activeEl.value : '';
const activeSelectionStart = activeIsBindingSearch && typeof activeEl.selectionStart === 'number' ? activeEl.selectionStart : null;
const activeSelectionEnd = activeIsBindingSearch && typeof activeEl.selectionEnd === 'number' ? activeEl.selectionEnd : null;
const restoreKey = activeIsBindingSearch ? activeEl.getAttribute('data-search-key') : null;
if (!functionBindingDefs.length) { el.innerHTML='<div class="muted small" style="padding:.5rem .6rem">No Studio bindings</div>'; return; }
el.innerHTML = `<div class="binding-list">${functionBindingDefs.map(renderBindingCard).join('')}</div>`;
if (restoreKey) {
const nextEl = el.querySelector(`.binding-role-search[data-search-key="${CSS.escape(restoreKey)}"]`);
if (nextEl) {
nextEl.focus();
if (nextEl.value !== activeValue) nextEl.value = activeValue;
if (activeSelectionStart !== null && activeSelectionEnd !== null && typeof nextEl.setSelectionRange === 'function') {
nextEl.setSelectionRange(activeSelectionStart, activeSelectionEnd);
}
}
}
}
function renderBindingCard(def) {
......@@ -1657,7 +1673,7 @@ function renderBindingRole(def, role) {
<div class="binding-role-state">${assignedModel ? 'Bound' : (role.optional ? 'Optional' : 'Missing')}</div>
</div>
<div class="binding-role-meta">${currentMeta}</div>
<input class="fi binding-role-search" type="search" value="${escapeHtml(query)}" placeholder="Search provider/model, rotation, autoselect" oninput="updateBindingSearch('${def.id}','${role.key}', this.value)">
<input class="fi binding-role-search" type="search" data-search-key="${escapeHtml(searchKey)}" value="${escapeHtml(query)}" placeholder="Search provider/model, rotation, autoselect" oninput="updateBindingSearch('${def.id}','${role.key}', this.value)">
<div class="binding-role-results">${results}</div>
${assignedModel ? `<button class="btn btn-ghost btn-sm binding-role-clear" onclick="clearBindingRole('${def.id}','${role.key}');return false;">Clear</button>` : ''}
</div>`;
......
import json
import os
from base64 import b64encode
from pathlib import Path
......@@ -6,6 +7,7 @@ from fastapi.testclient import TestClient
from itsdangerous import TimestampSigner
from aisbf.routes.dashboard import settings as dashboard_settings
from aisbf.config import Config
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
......@@ -192,3 +194,213 @@ def test_dashboard_settings_save_persists_feature_controls(tmp_path, monkeypatch
assert saved["feature_controls"]["prompt_security"]["persist_prompt_text"]["mode"] == "enabled"
assert saved["feature_controls"]["prompt_security"]["redact_before_persist"]["mode"] == "disabled"
assert saved["feature_controls"]["prompt_security"]["risk_threshold"] == "medium"
def test_dashboard_settings_save_writes_back_to_resolved_config_path(tmp_path, monkeypatch):
resolved_cfg_path = tmp_path / "custom-location.json"
resolved_cfg_path.write_text(json.dumps({
"server": {"host": "127.0.0.1", "port": 17765, "protocol": "http"},
"auth": {"enabled": False, "tokens": []},
"dashboard": {"username": "admin", "password": "hash"},
"internal_model": {
"condensation_model_id": "internal-condense",
"autoselect_model_id": "internal-autoselect",
"semantic_vectorization": "sentence-transformers/all-MiniLM-L6-v2",
},
"database": {"type": "sqlite", "sqlite_path": "~/.aisbf/aisbf.db", "mysql_host": "localhost", "mysql_port": 3306, "mysql_user": "aisbf", "mysql_password": "", "mysql_database": "aisbf"},
"cache": {"type": "file", "redis_host": "localhost", "redis_port": 6379, "redis_db": 0, "redis_password": "", "redis_key_prefix": "aisbf:"},
"response_cache": {"enabled": True, "backend": "memory", "ttl": 600, "max_memory_cache": 1000, "redis_host": "localhost", "redis_port": 6379, "redis_db": 0, "redis_password": "", "redis_key_prefix": "aisbf:response:", "sqlite_path": "~/.aisbf/response_cache.db", "mysql_host": "localhost", "mysql_port": 3306, "mysql_user": "aisbf", "mysql_password": "", "mysql_database": "aisbf_response_cache"},
"mcp": {"enabled": False, "autoselect_tokens": [], "fullconfig_tokens": []},
"tor": {"enabled": False, "control_port": 9051, "control_host": "127.0.0.1", "control_password": None, "hidden_service_dir": None, "hidden_service_port": 80, "socks_port": 9050, "socks_host": "127.0.0.1"},
"signup": {"enabled": False, "require_email_verification": False, "verification_token_expiry_hours": 24},
"smtp": {"enabled": False, "host": "", "port": 587, "username": "", "password": "", "use_tls": True, "use_ssl": False, "from_email": "", "from_name": "AISBF"},
"oauth2": {"google": {"enabled": False, "client_id": "", "client_secret": ""}, "github": {"enabled": False, "client_id": "", "client_secret": ""}},
"batching": {"enabled": False, "window_ms": 100, "max_batch_size": 8, "provider_settings": {"openai": {"enabled": False, "max_batch_size": 10}, "anthropic": {"enabled": False, "max_batch_size": 5}}},
"adaptive_rate_limiting": {"enabled": False, "initial_rate_limit": 0, "learning_rate": 0.1, "headroom_percent": 10, "recovery_rate": 0.05, "max_rate_limit": 60, "min_rate_limit": 0.1, "backoff_base": 2, "jitter_factor": 0.25, "history_window": 3600, "consecutive_successes_for_recovery": 10},
"client_rate_limiting": {"enabled": False, "api": {"requests_per_minute": 60, "requests_per_hour": 1000}, "general": {"requests_per_minute": 120, "requests_per_hour": 3000}},
}))
home_cfg_path = Path.home() / ".aisbf" / "aisbf.json"
original_home = home_cfg_path.read_text() if home_cfg_path.exists() else None
monkeypatch.setattr(dashboard_settings, "get_aisbf_config_path", lambda: resolved_cfg_path)
monkeypatch.setattr(dashboard_settings, "_reload_global_config", lambda: None)
class TemplateStub:
def TemplateResponse(self, *args, **kwargs):
from starlette.responses import Response
return Response(status_code=200)
monkeypatch.setattr(dashboard_settings, "_templates", TemplateStub())
client = TestClient(app)
_login_as_admin(client)
response = client.post(
"/dashboard/settings",
data={
"host": "127.0.0.1",
"port": 17765,
"protocol": "http",
"auth_enabled": "",
"auth_tokens": "",
"dashboard_username": "admin",
"condensation_model_id": "internal-condense",
"autoselect_model_id": "internal-autoselect",
"autoselect_max_tokens": 8000,
"condensation_max_tokens": 1000,
"autoselect_max_new_tokens": 100,
"nsfw_classifier": "michelleli99/NSFW_text_classifier",
"privacy_classifier": "iiiorg/piiranha-v1-detect-personal-information",
"semantic_vectorization": "sentence-transformers/all-MiniLM-L6-v2",
"feature_nsfw_classification_mode": "enabled",
"feature_privacy_classification_mode": "disabled",
"feature_context_condensation_mode": "enabled",
"feature_response_cache_mode": "disabled",
"feature_prompt_batching_mode": "enabled",
"feature_prompt_security_mode": "enabled",
"feature_context_lens_mode": "enabled",
"feature_block_high_risk_prompts_mode": "disabled",
"feature_persist_prompt_text_mode": "enabled",
"feature_redact_before_persist_mode": "disabled",
"feature_risk_threshold": "medium",
"batching_window_ms": 100,
"batching_max_batch_size": 8,
"batching_openai_max_batch_size": 10,
"batching_anthropic_max_batch_size": 5,
"adaptive_initial_rate_limit": 0,
"adaptive_learning_rate": 0.1,
"adaptive_headroom_percent": 10,
"adaptive_recovery_rate": 0.05,
"adaptive_max_rate_limit": 60,
"adaptive_min_rate_limit": 0.1,
"adaptive_backoff_base": 2,
"adaptive_jitter_factor": 0.25,
"adaptive_history_window": 3600,
"adaptive_consecutive_successes": 10,
"active_tab": "classification",
"database_type": "sqlite",
"sqlite_path": "~/.aisbf/aisbf.db",
"mysql_host": "localhost",
"mysql_port": 3306,
"mysql_user": "aisbf",
"mysql_password": "",
"mysql_database": "aisbf",
"cache_type": "file",
"redis_host": "localhost",
"redis_port": 6379,
"redis_db": 0,
"redis_password": "",
"redis_key_prefix": "aisbf:",
"response_cache_backend": "memory",
"response_cache_ttl": 600,
"response_cache_max_memory": 1000,
"response_cache_redis_host": "localhost",
"response_cache_redis_port": 6379,
"response_cache_redis_db": 0,
"response_cache_redis_password": "",
"response_cache_redis_key_prefix": "aisbf:response:",
"response_cache_sqlite_path": "~/.aisbf/response_cache.db",
"response_cache_mysql_host": "localhost",
"response_cache_mysql_port": 3306,
"response_cache_mysql_user": "aisbf",
"response_cache_mysql_password": "",
"response_cache_mysql_database": "aisbf_response_cache",
"autoselect_tokens": "",
"fullconfig_tokens": "",
"tor_control_port": 9051,
"tor_control_host": "127.0.0.1",
"tor_control_password": "",
"tor_hidden_service_dir": "",
"tor_hidden_service_port": 80,
"tor_socks_port": 9050,
"tor_socks_host": "127.0.0.1",
"verification_token_expiry": 24,
"smtp_host": "",
"smtp_port": 587,
"smtp_username": "",
"smtp_password": "",
"smtp_from_email": "",
"smtp_from_name": "AISBF",
"oauth2_google_client_id": "",
"oauth2_google_client_secret": "",
"oauth2_github_client_id": "",
"oauth2_github_client_secret": "",
"dashboard_email": "",
"new_admin_password": "",
"confirm_admin_password": "",
"client_rl_api_rpm": 60,
"client_rl_api_rph": 1000,
"client_rl_general_rpm": 120,
"client_rl_general_rph": 3000,
},
follow_redirects=False,
)
assert response.status_code == 200
saved = json.loads(resolved_cfg_path.read_text())
assert saved["feature_controls"]["prompt_security"]["security_scan"]["mode"] == "enabled"
if original_home is None:
if home_cfg_path.exists():
home_cfg_path.unlink()
else:
home_cfg_path.parent.mkdir(parents=True, exist_ok=True)
home_cfg_path.write_text(original_home)
def test_config_reload_reads_feature_controls_from_resolved_config_path(tmp_path):
custom_dir = tmp_path / "custom-config"
custom_dir.mkdir()
previous_config_dir = os.environ.get("AISBF_CONFIG_DIR")
(custom_dir / "providers.json").write_text(json.dumps({"providers": {}}))
(custom_dir / "rotations.json").write_text(json.dumps({"rotations": {}}))
(custom_dir / "autoselect.json").write_text(json.dumps({}))
(custom_dir / "aisbf.json").write_text(json.dumps({
"server": {"host": "127.0.0.1", "port": 17765, "protocol": "http"},
"auth": {"enabled": False, "tokens": []},
"dashboard": {"username": "admin", "password": "hash"},
"internal_model": {
"condensation_model_id": "internal-condense",
"autoselect_model_id": "internal-autoselect",
"semantic_vectorization": "sentence-transformers/all-MiniLM-L6-v2"
},
"feature_controls": {
"nsfw_classification": {"mode": "enabled"},
"privacy_classification": {"mode": "disabled"},
"context_condensation": {"mode": "enabled"},
"response_cache": {"mode": "enabled"},
"prompt_batching": {"mode": "enabled"},
"prompt_security": {
"security_scan": {"mode": "enabled"},
"context_lens": {"mode": "enabled"},
"block_high_risk_prompts": {"mode": "disabled"},
"persist_prompt_text": {"mode": "enabled"},
"redact_before_persist": {"mode": "disabled"},
"risk_threshold": "medium"
}
},
"response_cache": {"enabled": False, "backend": "memory", "ttl": 600, "max_memory_cache": 1000},
"batching": {"enabled": False, "window_ms": 100, "max_batch_size": 8, "provider_settings": {}},
"adaptive_rate_limiting": {"enabled": False},
"client_rate_limiting": {"enabled": False, "api": {"requests_per_minute": 60, "requests_per_hour": 1000}, "general": {"requests_per_minute": 120, "requests_per_hour": 3000}}
}))
os.environ["AISBF_CONFIG_DIR"] = str(custom_dir)
try:
cfg = Config()
assert cfg.resolve_feature_enabled("nsfw_classification") is True
assert cfg.resolve_feature_enabled("privacy_classification") is False
assert cfg.resolve_feature_enabled("context_condensation") is True
assert cfg.resolve_feature_enabled("response_cache") is True
assert cfg.resolve_feature_enabled("prompt_batching") is True
assert cfg.resolve_feature_enabled("prompt_security") is True
assert cfg.resolve_feature_enabled("context_lens") is True
assert cfg.resolve_feature_enabled("block_high_risk_prompts") is False
finally:
if previous_config_dir is None:
os.environ.pop("AISBF_CONFIG_DIR", None)
else:
os.environ["AISBF_CONFIG_DIR"] = previous_config_dir
import json
from aisbf.coderai_broker import CoderAIBroker
def test_coderai_broker_send_request_falls_back_to_client_id_named_provider_snapshot(tmp_path):
broker = CoderAIBroker()
broker._state_path = tmp_path / "coderai_broker_sessions.json"
session_key = broker._session_meta_key("zeiss-nvidia", "zeiss-nvidia")
broker._cache.broker_set(
session_key,
{
"session_id": "sess-1",
"provider_id": "actual-coderai-provider",
"client_id": "zeiss-nvidia",
"closed": False,
"metadata": {"owner_user_id": None},
},
ttl=120,
)
import asyncio
async def _run():
task = asyncio.create_task(
broker.send_request("zeiss-nvidia", "models.list", {}, client_id="zeiss-nvidia", owner_user_id=None, timeout=0.01)
)
await asyncio.sleep(0)
pending = next(iter(broker._pending.values()))
assert pending.request_snapshot["provider_id"] == "actual-coderai-provider"
task.cancel()
try:
await task
except BaseException:
pass
asyncio.run(_run())
def test_studio_sidebar_search_input_declares_stable_search_key():
studio_js = open("/working/aisbf/static/dashboard/studio.js", "r", encoding="utf-8").read()
assert "data-search-key" in studio_js
assert "setSelectionRange" in studio_js
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