Make platform defaults OS-aware and balance whisper aliases

parent 52316b8b
......@@ -54,7 +54,10 @@ def get_or_create_secret(config_dir: Path) -> bytes:
secret = secrets.token_bytes(32)
with open(secret_path, 'wb') as f:
f.write(secret)
secret_path.chmod(0o600)
try:
secret_path.chmod(0o600)
except OSError:
pass
return secret
......
......@@ -25,6 +25,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, Stre
from fastapi.templating import Jinja2Templates
from codai.admin.auth import SessionManager
from codai.platform_paths import default_whisper_server_path
import queue as _q
import threading as _t
import uuid as _uuid
......@@ -90,7 +91,7 @@ def _next_whisper_server_model_id(audio_models) -> str:
def _default_whisper_server_path() -> str:
return shutil.which("whisper-server") or "/usr/local/bin/whisper-server"
return shutil.which("whisper-server") or default_whisper_server_path()
def get_current_user(request: Request) -> Optional[str]:
......@@ -239,7 +240,11 @@ async def admin_dashboard(request: Request, username: str = Depends(require_auth
@router.get("/admin/models", response_class=HTMLResponse)
async def models_page(request: Request, username: str = Depends(require_admin)):
return _tmpl(request, "models.html", {"username": username, "is_admin": True})
return _tmpl(request, "models.html", {
"username": username,
"is_admin": True,
"default_whisper_server_path": _default_whisper_server_path(),
})
@router.get("/admin/tokens", response_class=HTMLResponse)
......@@ -1434,12 +1439,14 @@ async def api_model_configure(request: Request, username: str = Depends(require_
gpu_device = int(data.get("gpu_device", 0))
if gpu_device < 0:
raise HTTPException(status_code=400, detail="gpu_device must be >= 0")
# Remove existing entry with same id (update semantics)
audio_list = config_manager.models_data.get("audio_models", [])
config_manager.models_data["audio_models"] = [
m for m in audio_list
if not (isinstance(m, dict) and m.get("id") == model_id)
]
if any(
isinstance(m, dict)
and m.get("backend") == "whisper-server"
and m.get("id") == model_id
for m in audio_list
):
raise HTTPException(status_code=409, detail=f"whisper-server model '{model_id}' already exists")
alias = (data.get("alias") or "").strip() or None
entry = {
"id": model_id,
......
......@@ -26,6 +26,9 @@
{% endblock %}
{% block content %}
<script>
window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }};
</script>
<div class="page-header">
<div>
<h1>Models</h1>
......@@ -1253,7 +1256,7 @@ function nextWhisperServerModelId(){
}
function defaultWhisperServerPath(){
return '/usr/local/bin/whisper-server';
return window.__DEFAULT_WHISPER_SERVER_PATH__ || '';
}
function resetWhisperServerBuilderDefaults(){
......
......@@ -40,6 +40,8 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, ConfigDict
from codai.platform_paths import default_characters_dir, legacy_style_config_dir
router = APIRouter()
_CHARS_DIR: Optional[str] = None
......@@ -47,8 +49,8 @@ _CHARS_DIR: Optional[str] = None
def set_global_args(args):
global _CHARS_DIR
base = getattr(args, 'file_path', None) or os.path.expanduser('~/.coderai')
root = base if os.path.isdir(base) else (os.path.dirname(base) if base else os.path.expanduser('~/.coderai'))
base = getattr(args, 'file_path', None) or str(legacy_style_config_dir())
root = base if os.path.isdir(base) else (os.path.dirname(base) if base else str(legacy_style_config_dir()))
_CHARS_DIR = os.path.join(root, 'characters')
os.makedirs(_CHARS_DIR, exist_ok=True)
......@@ -60,7 +62,7 @@ def set_global_file_path(path: str):
def _chars_dir() -> str:
if _CHARS_DIR:
return _CHARS_DIR
d = os.path.expanduser('~/.coderai/characters')
d = str(default_characters_dir())
os.makedirs(d, exist_ok=True)
return d
......
......@@ -42,6 +42,8 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, ConfigDict
from codai.platform_paths import default_environments_dir, legacy_style_config_dir
router = APIRouter()
_ENVS_DIR: Optional[str] = None
......@@ -49,8 +51,8 @@ _ENVS_DIR: Optional[str] = None
def set_global_args(args):
global _ENVS_DIR
base = getattr(args, 'file_path', None) or os.path.expanduser('~/.coderai')
root = base if os.path.isdir(base) else (os.path.dirname(base) if base else os.path.expanduser('~/.coderai'))
base = getattr(args, 'file_path', None) or str(legacy_style_config_dir())
root = base if os.path.isdir(base) else (os.path.dirname(base) if base else str(legacy_style_config_dir()))
_ENVS_DIR = os.path.join(root, 'environments')
os.makedirs(_ENVS_DIR, exist_ok=True)
......@@ -62,7 +64,7 @@ def set_global_file_path(path: str):
def _envs_dir() -> str:
if _ENVS_DIR:
return _ENVS_DIR
d = os.path.expanduser('~/.coderai/environments')
d = str(default_environments_dir())
os.makedirs(d, exist_ok=True)
return d
......
......@@ -20,13 +20,14 @@ from PIL import Image
from pydantic import BaseModel, ConfigDict
from codai.api.images import save_image_response
from codai.platform_paths import default_insightface_model_path
router = APIRouter()
global_args = None
global_file_path = None
_INSWAPPER_MODEL_PATH = os.path.expanduser('~/.insightface/models/inswapper_128.onnx')
_INSWAPPER_MODEL_PATH = str(default_insightface_model_path())
_INSWAPPER_HF_REPO = 'deepinsight/inswapper'
_INSWAPPER_HF_FILE = 'inswapper_128.onnx'
......
......@@ -1713,7 +1713,8 @@ def _run_unpixelate(image_bytes: bytes, scale: int, model_path: Optional[str]) -
mp = model_path
else:
# Download RealESRGAN_x4plus on demand
mp = os.path.expanduser('~/.cache/realesrgan/RealESRGAN_x4plus.pth')
from codai.platform_paths import default_realesrgan_model_path
mp = str(default_realesrgan_model_path())
if not os.path.exists(mp):
os.makedirs(os.path.dirname(mp), exist_ok=True)
import urllib.request
......
......@@ -136,9 +136,11 @@ async def create_transcription(
# Check if the requested model maps to a configured whisper-server instance first.
# Try alias round-robin resolution before direct ID lookup.
whisper_model_id = multi_model_manager.resolve_whisper_alias_model_id(model)
whisper_server = (
multi_model_manager.resolve_whisper_alias(model)
or multi_model_manager.whisper_servers.get(model)
multi_model_manager.whisper_servers.get(whisper_model_id)
if whisper_model_id is not None
else multi_model_manager.whisper_servers.get(model)
)
if whisper_server is not None:
multi_model_manager.request_model(requested_model=model, model_type="audio")
......@@ -148,7 +150,7 @@ async def create_transcription(
gpu_device=getattr(whisper_server, "_gpu_device", 0),
)
if whisper_server.is_running():
ws_key = f"audio:{model}"
ws_key = f"audio:{whisper_model_id or model}"
multi_model_manager.models[ws_key] = whisper_server
multi_model_manager.active_in_vram = ws_key
multi_model_manager.models_in_vram.add(ws_key)
......
......@@ -22,6 +22,8 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
from pydantic import BaseModel, ConfigDict
from codai.platform_paths import default_voices_dir
router = APIRouter()
global_args = None
......@@ -35,8 +37,8 @@ def set_global_args(args):
global global_args, _VOICES_DIR
global_args = args
# Store voice profiles alongside output files, or in a default location
base = getattr(args, 'file_path', None) or os.path.expanduser('~/.coderai/voices')
_VOICES_DIR = os.path.join(base if os.path.isdir(base) else os.path.dirname(base) if base else os.path.expanduser('~/.coderai'), 'voices')
base = getattr(args, 'file_path', None) or str(default_voices_dir())
_VOICES_DIR = os.path.join(base if os.path.isdir(base) else os.path.dirname(base) if base else str(default_voices_dir().parent), 'voices')
os.makedirs(_VOICES_DIR, exist_ok=True)
......@@ -48,7 +50,7 @@ def set_global_file_path(path):
def _voices_dir() -> str:
if _VOICES_DIR:
return _VOICES_DIR
d = os.path.expanduser('~/.coderai/voices')
d = str(default_voices_dir())
os.makedirs(d, exist_ok=True)
return d
......
......@@ -20,6 +20,8 @@ import json
import os
from pathlib import Path
from codai.platform_paths import legacy_style_config_dir
def load_config_file(config_dir: Path) -> dict:
"""Load the main config.json file."""
......@@ -166,11 +168,12 @@ def setup_default_config(config_dir: Path):
def parse_args():
"""Parse command line arguments."""
default_config = str(legacy_style_config_dir())
parser = argparse.ArgumentParser(
description="OpenAI-compatible API server supporting NVIDIA (CUDA) and Vulkan backends",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Configuration: All settings are loaded from JSON config files in the
configuration directory (--config DIR, default: ~/.coderai/). Key files:
configuration directory (--config DIR, default: OS-specific CoderAI directory). Key files:
config.json - Server and backend settings
models.json - Model registry and configurations
auth.json - Users, tokens, and sessions"""
......@@ -178,8 +181,8 @@ configuration directory (--config DIR, default: ~/.coderai/). Key files:
parser.add_argument(
"--config",
type=str,
default=os.path.expanduser("~/.coderai/"),
help="Configuration directory (default: ~/.coderai/)",
default=default_config,
help=f"Configuration directory (default: {default_config})",
)
parser.add_argument(
"--debug",
......@@ -224,4 +227,4 @@ configuration directory (--config DIR, default: ~/.coderai/). Key files:
action="store_true",
help="List available Vulkan GPU devices and exit",
)
return parser.parse_args()
\ No newline at end of file
return parser.parse_args()
......@@ -40,6 +40,8 @@ import hashlib
import pathlib
from typing import Optional, Dict, List, Tuple
from codai.platform_paths import default_model_cache_dir, legacy_style_cache_root
# For type hints
import time
......@@ -49,8 +51,7 @@ def get_model_cache_dir() -> str:
if os.environ.get('CODERAI_CACHE_DIR'):
cache_dir = os.environ['CODERAI_CACHE_DIR']
else:
cache_home = os.environ.get('XDG_CACHE_HOME', os.path.expanduser('~/.cache'))
cache_dir = os.path.join(cache_home, 'coderai', 'models')
cache_dir = str(default_model_cache_dir())
pathlib.Path(cache_dir).mkdir(parents=True, exist_ok=True)
return cache_dir
......@@ -58,7 +59,7 @@ def get_model_cache_dir() -> str:
def get_all_cache_dirs() -> dict:
"""Get all model cache directories."""
caches = {}
cache_home = os.environ.get('XDG_CACHE_HOME', os.path.expanduser('~/.cache'))
cache_home = str(legacy_style_cache_root())
# Coderai GGUF cache
coderai_cache = get_model_cache_dir()
......@@ -79,7 +80,7 @@ def get_all_cache_dirs() -> dict:
caches['huggingface'] = hf_cache
# Local diffusers cache
local_diffusers = os.path.expanduser('~/.cache/diffusers')
local_diffusers = os.path.join(cache_home, 'diffusers')
if os.path.exists(local_diffusers):
caches['diffusers'] = local_diffusers
......@@ -549,4 +550,4 @@ __all__ = [
'remove_cached_model',
'list_cached_models_info',
'remove_all_cached_models',
]
\ No newline at end of file
]
......@@ -18,6 +18,7 @@
from typing import Optional, Dict, Any, List
import os
import random
import subprocess
import signal
import requests
......@@ -256,6 +257,7 @@ class WhisperServerManager:
self.current_model = None
self.base_url = f"http://127.0.0.1:{port}"
self.lock = threading.Lock()
self._active_requests = 0
# Check if port is available
if not self._is_port_available(port):
......@@ -351,7 +353,8 @@ class WhisperServerManager:
"""Send transcription request to whisper-server."""
if not self.is_running():
return {"error": "whisper-server not running"}
with self.lock:
self._active_requests += 1
try:
files = {"file": ("audio.wav", audio_data, "audio/wav")}
data = {}
......@@ -373,6 +376,16 @@ class WhisperServerManager:
return {"error": f"Server error: {response.status_code}", "detail": response.text}
except Exception as e:
return {"error": str(e)}
finally:
with self.lock:
self._active_requests = max(0, self._active_requests - 1)
def active_requests(self) -> int:
with self.lock:
return self._active_requests
def is_idle(self) -> bool:
return self.active_requests() == 0
def _wait_for_server(self, timeout: int = 30) -> bool:
"""Wait for whisper-server to be ready."""
......@@ -810,6 +823,17 @@ class MultiModelManager:
idx = self._whisper_alias_counters.get(name, 0) % len(ids)
self._whisper_alias_counters[name] = idx + 1
return self.whisper_servers.get(ids[idx])
def resolve_whisper_alias_model_id(self, name: str) -> Optional[str]:
"""Return an idle whisper-server model id for an alias, else a random one."""
ids = self.whisper_aliases.get(name)
if not ids:
return None
idle_ids = [mid for mid in ids if (self.whisper_servers.get(mid) and self.whisper_servers[mid].is_idle())]
choices = idle_ids or list(ids)
if len(choices) == 1:
return choices[0]
return random.choice(choices)
def set_tts_model(self, model_name: str, config: Dict = None):
"""Set the text-to-speech model and download/cache it if needed."""
......@@ -900,6 +924,9 @@ class MultiModelManager:
for m in self.audio_models:
allowed.add(m)
allowed.add(f"audio:{m}")
for alias in self.whisper_aliases:
allowed.add(alias)
allowed.add(f"audio:{alias}")
# TTS model
if self.tts_model:
......@@ -963,10 +990,18 @@ class MultiModelManager:
"video_models", "audio_gen_models", "embedding_models",
"spatial_models"):
for m in md.get(cat, []):
mid = (m if isinstance(m, str) else
m.get("alias") or m.get("path") or m.get("id") or "")
if isinstance(m, str):
mid = m
elif m.get("backend") == "whisper-server":
mid = m.get("id") or ""
else:
mid = m.get("alias") or m.get("path") or m.get("id") or ""
raw = (m if isinstance(m, str) else m.get("path") or m.get("id") or "")
for val in (mid, raw):
alias = "" if isinstance(m, str) else (m.get("alias") or "")
vals = [mid, raw]
if alias:
vals.append(alias)
for val in vals:
if val:
allowed.add(val)
short = val.split("/")[-1] if "/" in val else val
......
"""Platform-aware filesystem helpers for CoderAI."""
from __future__ import annotations
import os
from pathlib import Path
APP_NAME = "coderai"
def _home_dir() -> Path:
return Path.home()
def _windows_dir(env_var: str, fallback: Path) -> Path:
value = os.environ.get(env_var)
return Path(value).expanduser() if value else fallback
def user_config_dir() -> Path:
if os.name == "nt":
base = _windows_dir("APPDATA", _home_dir() / "AppData" / "Roaming")
return base / "CoderAI"
if sys_platform() == "darwin":
return _home_dir() / "Library" / "Application Support" / "CoderAI"
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg).expanduser() if xdg else (_home_dir() / ".config")
return base / APP_NAME
def user_data_dir() -> Path:
if os.name == "nt":
base = _windows_dir("LOCALAPPDATA", _home_dir() / "AppData" / "Local")
return base / "CoderAI"
if sys_platform() == "darwin":
return _home_dir() / "Library" / "Application Support" / "CoderAI"
xdg = os.environ.get("XDG_DATA_HOME")
base = Path(xdg).expanduser() if xdg else (_home_dir() / ".local" / "share")
return base / APP_NAME
def user_cache_dir() -> Path:
if os.name == "nt":
base = _windows_dir("LOCALAPPDATA", _home_dir() / "AppData" / "Local")
return base / "CoderAI" / "Cache"
if sys_platform() == "darwin":
return _home_dir() / "Library" / "Caches" / "CoderAI"
xdg = os.environ.get("XDG_CACHE_HOME")
base = Path(xdg).expanduser() if xdg else (_home_dir() / ".cache")
return base / APP_NAME
def ensure_dir(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
def default_config_dir() -> Path:
return ensure_dir(user_config_dir())
def default_data_dir() -> Path:
return ensure_dir(user_data_dir())
def default_cache_dir() -> Path:
return ensure_dir(user_cache_dir())
def legacy_style_config_dir() -> Path:
if os.name == "nt":
return _home_dir() / ".coderai"
return _home_dir() / ".coderai"
def legacy_style_cache_root() -> Path:
if os.name == "nt":
base = _windows_dir("LOCALAPPDATA", _home_dir() / "AppData" / "Local")
return base / ".cache"
if sys_platform() == "darwin":
return _home_dir() / "Library" / "Caches"
xdg = os.environ.get("XDG_CACHE_HOME")
return Path(xdg).expanduser() if xdg else (_home_dir() / ".cache")
def default_model_cache_dir() -> Path:
return ensure_dir(legacy_style_cache_root() / APP_NAME / "models")
def default_diffusers_cache_dir() -> Path:
return ensure_dir(legacy_style_cache_root() / "diffusers")
def default_realesrgan_model_path() -> Path:
return default_diffusers_cache_dir().parent / "realesrgan" / "RealESRGAN_x4plus.pth"
def default_insightface_model_path() -> Path:
return ensure_dir(legacy_style_cache_root() / "insightface" / "models") / "inswapper_128.onnx"
def default_voices_dir() -> Path:
return ensure_dir(legacy_style_config_dir() / "voices")
def default_characters_dir() -> Path:
return ensure_dir(legacy_style_config_dir() / "characters")
def default_environments_dir() -> Path:
return ensure_dir(legacy_style_config_dir() / "environments")
def default_whisper_server_path() -> str:
if os.name == "nt":
local = _windows_dir("LOCALAPPDATA", _home_dir() / "AppData" / "Local")
return str(local / "Programs" / "whisper-server" / "whisper-server.exe")
if sys_platform() == "darwin":
return "/usr/local/bin/whisper-server"
return "/usr/local/bin/whisper-server"
def sys_platform() -> str:
return os.sys.platform
......@@ -247,6 +247,7 @@ def test_model_configure_allows_whisper_server_id_coexistence_with_other_audio_b
def test_model_configure_defaults_missing_whisper_server_path_to_usr_local_bin(monkeypatch, tmp_path):
from codai.admin import routes
from codai.platform_paths import default_whisper_server_path
cfg = _build_config(tmp_path)
monkeypatch.setattr(routes, "config_manager", cfg, raising=False)
......@@ -267,7 +268,7 @@ def test_model_configure_defaults_missing_whisper_server_path_to_usr_local_bin(m
)
assert response.status_code == 200
assert cfg.models_data["audio_models"][0]["server_path"] == "/usr/local/bin/whisper-server"
assert cfg.models_data["audio_models"][0]["server_path"] == default_whisper_server_path()
app.dependency_overrides.clear()
......@@ -546,6 +547,67 @@ def test_transcription_requires_configured_whisper_server_model_id():
assert "not configured" in str(exc.value.detail).lower() or "not available" in str(exc.value.detail).lower()
def test_whisper_alias_prefers_idle_instance(monkeypatch):
from codai.models.manager import MultiModelManager
manager = MultiModelManager()
busy = SimpleNamespace(is_idle=lambda: False)
idle = SimpleNamespace(is_idle=lambda: True)
manager.whisper_servers = {"whisper0": busy, "whisper1": idle}
manager.whisper_aliases = {"shared-whisper": ["whisper0", "whisper1"]}
assert manager.resolve_whisper_alias_model_id("shared-whisper") == "whisper1"
def test_whisper_alias_uses_random_choice_when_multiple_idle(monkeypatch):
from codai.models.manager import MultiModelManager
manager = MultiModelManager()
idle0 = SimpleNamespace(is_idle=lambda: True)
idle1 = SimpleNamespace(is_idle=lambda: True)
manager.whisper_servers = {"whisper0": idle0, "whisper1": idle1}
manager.whisper_aliases = {"shared-whisper": ["whisper0", "whisper1"]}
monkeypatch.setattr("codai.models.manager.random.choice", lambda items: items[-1])
assert manager.resolve_whisper_alias_model_id("shared-whisper") == "whisper1"
def test_get_all_allowed_identifiers_includes_whisper_alias(monkeypatch):
from codai.admin import routes
from codai.models.manager import MultiModelManager
manager = MultiModelManager()
manager.audio_models[:] = ["whisper0", "whisper1"]
manager.whisper_aliases = {"shared-whisper": ["whisper0", "whisper1"]}
monkeypatch.setattr(
routes,
"config_manager",
SimpleNamespace(
models_data={
"text_models": [],
"image_models": [],
"audio_models": [
{"id": "whisper0", "backend": "whisper-server", "alias": "shared-whisper"},
{"id": "whisper1", "backend": "whisper-server", "alias": "shared-whisper"},
],
"vision_models": [],
"tts_models": [],
"gguf_models": [],
"video_models": [],
"audio_gen_models": [],
"embedding_models": [],
"aliases": {},
}
),
raising=False,
)
allowed = manager.get_all_allowed_identifiers()
assert "shared-whisper" in allowed
assert "audio:shared-whisper" in allowed
def test_get_all_allowed_identifiers_includes_configured_whisper_server_id_without_legacy_alias(monkeypatch):
from codai.admin import routes
from codai.models.manager import MultiModelManager
......@@ -673,7 +735,7 @@ def test_models_template_defines_whisper_server_builder_default_helpers():
template = Path("codai/admin/templates/models.html").read_text()
assert "function defaultWhisperServerPath()" in template
assert "return '/usr/local/bin/whisper-server';" in template
assert "return window.__DEFAULT_WHISPER_SERVER_PATH__ || '';" in template
assert "function resetWhisperServerBuilderDefaults()" in template
assert "const modelIdInput = document.getElementById('ws-model-id');" in template
assert "const serverPathInput = document.getElementById('ws-server-path');" in template
......
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