Commit 3716e54b authored by Stefy Lanza (nextime / spora )'s avatar Stefy Lanza (nextime / spora )

Merge branch 'experimental'

parents 52316b8b ffd12aa6
...@@ -128,6 +128,22 @@ source venv_all/bin/activate ...@@ -128,6 +128,22 @@ source venv_all/bin/activate
python coderai # starts on http://127.0.0.1:8776 python coderai # starts on http://127.0.0.1:8776
``` ```
macOS:
```bash
./osxbuild.sh all
source venv_osx_all/bin/activate
python coderai
```
Windows PowerShell:
```powershell
.\build.ps1 -Backend all
.\venv_win_all\Scripts\Activate.ps1
python coderai
```
That's it. Open `http://127.0.0.1:8776/admin` and log in with `admin` / `admin`. That's it. Open `http://127.0.0.1:8776/admin` and log in with `admin` / `admin`.
--- ---
...@@ -152,8 +168,67 @@ cd coderai ...@@ -152,8 +168,67 @@ cd coderai
./build.sh vulkan # AMD/Intel only ./build.sh vulkan # AMD/Intel only
``` ```
Platform-specific alternatives:
```bash
./osxbuild.sh all # macOS, prefers Metal-backed builds when available
```
```powershell
.\build.ps1 -Backend all # Windows, prefers CUDA-backed builds when available
```
Packaging options:
```bash
./build.sh all --package
./osxbuild.sh all --package
```
```powershell
.\build.ps1 -Backend all -Package
```
`--package` installs PyInstaller into the build virtual environment and produces a self-contained distributable from the venv that was just created or updated.
Packaging outputs:
- Linux: `dist-package/coderai`
- macOS: `dist-package/coderai` and `dist-package/CoderAI.app`
- Windows: `dist-package/coderai.exe`
Packaging notes:
- macOS does have an equivalent to a standalone packaged app: a `.app` bundle. `osxbuild.sh --package` now builds both a single CLI binary and a macOS app bundle.
- These packages bundle the Python interpreter and Python modules from the venv, but they do not eliminate the need for compatible external GPU/runtime drivers on the target machine.
- CUDA builds on Linux and Windows still require matching NVIDIA driver/runtime support on the destination system.
- Metal builds on macOS still require a compatible macOS system with Metal support.
The build script creates a virtual environment, installs dependencies, and builds GPU-accelerated backends including `stable-diffusion-cpp-python` with CUDA+Vulkan support. The build script creates a virtual environment, installs dependencies, and builds GPU-accelerated backends including `stable-diffusion-cpp-python` with CUDA+Vulkan support.
Platform backend notes:
- Linux: CUDA for NVIDIA, Vulkan for AMD/Intel/NVIDIA, OpenCL fallback where supported.
- macOS: Metal is the correct GPU acceleration path instead of CUDA. `osxbuild.sh` uses PyTorch MPS plus `GGML_METAL` / `SD_METAL` builds where available.
- Windows: CUDA remains the primary NVIDIA acceleration path. `build.ps1` focuses on CUDA or CPU installs.
- There is no general-purpose CUDA workflow for current macOS systems; Apple GPU acceleration uses Metal.
### Platform Support Matrix
| Capability | Linux | macOS | Windows |
|---|---|---|---|
| Core server / admin UI | Yes | Yes | Yes |
| Default path handling | Yes | Yes | Yes |
| PyTorch GPU acceleration | CUDA | Metal (MPS) | CUDA |
| `llama-cpp-python` GPU path | CUDA / Vulkan | Metal | CUDA |
| `stable-diffusion-cpp-python` GPU path | CUDA / Vulkan / OpenCL | Metal | CUDA |
| `whisper.cpp` accelerated path | Vulkan / CPU fallback | Metal / CPU fallback | CPU fallback |
| InsightFace / ONNX runtime | `onnxruntime-gpu` | `onnxruntime-silicon` or CPU | `onnxruntime-gpu` |
| Build script included in repo | `build.sh` | `osxbuild.sh` | `build.ps1` |
Notes:
- "Yes" means CoderAI has an intended path for that platform, not that every optional dependency is guaranteed to install on every machine.
- macOS GPU acceleration is Metal-based; there is no standard modern CUDA path for macOS.
- Windows currently uses CUDA as the main NVIDIA acceleration path; Vulkan/OpenCL build flows are not the primary Windows setup in this repository.
- Some optional audio and media packages may still vary by Python version, hardware, and upstream wheel availability.
### Manual Installation ### Manual Installation
```bash ```bash
...@@ -247,6 +322,45 @@ Config files live in `~/.coderai/` (or `--config` path): ...@@ -247,6 +322,45 @@ Config files live in `~/.coderai/` (or `--config` path):
└── secret_key # Session signing key (auto-generated) └── secret_key # Session signing key (auto-generated)
``` ```
### AISBF Broker Client
CoderAI includes an AISBF broker websocket client that can register this instance
with a broker and receive brokered requests.
You can configure it either by editing `config.json` directly or from the admin
Settings page under `AISBF Broker`.
Example broker configuration:
```json
{
"broker": {
"enabled": true,
"base_url": "https://broker.example.com",
"scope": "user",
"username": "alice",
"provider_id": "coderai-local",
"client_id": "workstation-01",
"registration_token": "your-registration-token",
"advertised_endpoint": "http://127.0.0.1:8776",
"transport": "websocket",
"heartbeat_interval_seconds": 30,
"connect_timeout_seconds": 10,
"request_timeout_seconds": 30,
"reconnect_initial_delay_seconds": 1,
"reconnect_max_delay_seconds": 60
}
}
```
Broker notes:
- `base_url` accepts `http`, `https`, `ws`, or `wss`; the websocket route is derived automatically.
- `scope: "user"` requires a non-global `username`.
- `scope: "global"` requires `username: "global"`.
- When `enabled` is `true`, `provider_id`, `client_id`, and `registration_token` are required.
- `advertised_endpoint` is optional and is sent to the broker as the externally reachable endpoint for this instance.
- Restart CoderAI after changing broker settings so the background broker service reconnects with the new configuration.
### config.json ### config.json
```json ```json
......
param(
[string]$Backend = "all",
[string]$Venv = "",
[switch]$Package
)
$ErrorActionPreference = "Stop"
$Backend = $Backend.ToLowerInvariant()
$validBackends = @("cuda", "cpu", "all")
if ($validBackends -notcontains $Backend) {
throw "Invalid backend '$Backend'. Use one of: cuda, cpu, all"
}
function Write-Info($msg) { Write-Host $msg -ForegroundColor Cyan }
function Write-Warn($msg) { Write-Host $msg -ForegroundColor Yellow }
function Write-Ok($msg) { Write-Host $msg -ForegroundColor Green }
Write-Info "========================================"
Write-Info " CoderAI Windows Build Script"
Write-Info " Backend: $Backend"
Write-Info "========================================"
$python = if (Get-Command py -ErrorAction SilentlyContinue) { "py" } else { "python" }
$pythonArgs = if ($python -eq "py") { @("-3") } else { @() }
if ([string]::IsNullOrWhiteSpace($Venv)) {
switch ($Backend) {
"cuda" { $Venv = "venv_win_cuda" }
"cpu" { $Venv = "venv_win_cpu" }
default { $Venv = "venv_win_all" }
}
}
if (-not (Test-Path $Venv)) {
Write-Info "Creating virtual environment: $Venv"
& $python @pythonArgs -m venv $Venv
} else {
Write-Warn "Using existing virtual environment: $Venv"
}
$venvPython = Join-Path $Venv "Scripts\python.exe"
$venvActivate = Join-Path $Venv "Scripts\Activate.ps1"
& $venvPython -m pip install --upgrade pip setuptools wheel
& $venvPython -m pip install -r requirements.txt
function Install-CommonMLStack {
& $venvPython -m pip install "imageio[ffmpeg]" scipy soundfile sentence-transformers openai-whisper argostranslate edge-tts kokoro-tts timm
& $venvPython -m pip install realesrgan basicsr
& $venvPython -m pip install demucs deepfilternet rnnoise voicefixer
& $venvPython -m pip install insightface onnxruntime-gpu
& $venvPython -m pip install f5-tts seed-vc
try { & $venvPython -m pip install audiocraft } catch { Write-Warn "audiocraft install failed; continuing" }
}
function Install-CudaStack {
Write-Info "Installing PyTorch with CUDA support..."
& $venvPython -m pip install torch torchvision torchaudio
Write-Info "Installing NVIDIA-specific requirements..."
try { & $venvPython -m pip install -r requirements-nvidia.txt } catch { Write-Warn "Some NVIDIA-specific packages failed; continuing" }
Write-Info "Installing llama-cpp-python with CUDA support..."
$env:CMAKE_ARGS = "-DGGML_CUDA=ON"
try { & $venvPython -m pip install --upgrade llama-cpp-python --no-cache-dir } finally { Remove-Item Env:CMAKE_ARGS -ErrorAction SilentlyContinue }
Write-Info "Installing stable-diffusion-cpp-python with CUDA support..."
$env:CMAKE_ARGS = "-DSD_CUDA=ON -DSD_WEBM=OFF"
try { & $venvPython -m pip install stable-diffusion-cpp-python --no-cache-dir } catch { Write-Warn "stable-diffusion-cpp-python CUDA build failed; continuing" } finally { Remove-Item Env:CMAKE_ARGS -ErrorAction SilentlyContinue }
}
function Install-CpuStack {
Write-Info "Installing CPU-oriented runtime..."
& $venvPython -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
& $venvPython -m pip install --upgrade llama-cpp-python
try { & $venvPython -m pip install stable-diffusion-cpp-python } catch { Write-Warn "stable-diffusion-cpp-python CPU install failed; continuing" }
try { & $venvPython -m pip install onnxruntime } catch { Write-Warn "onnxruntime CPU install failed; continuing" }
}
function Invoke-PackageBuild {
Write-Info "Packaging CoderAI with PyInstaller..."
& $venvPython -m pip install pyinstaller
New-Item -ItemType Directory -Force -Path "dist-package" | Out-Null
& $venvPython -m PyInstaller --clean --noconfirm --onefile --name coderai `
--collect-all codai `
--collect-all fastapi `
--collect-all uvicorn `
--collect-all pydantic `
--collect-all transformers `
--collect-all diffusers `
--collect-all sentence_transformers `
--collect-all whispercpp `
--collect-all insightface `
--collect-all onnxruntime `
--collect-all PIL `
coderai
Copy-Item "dist\coderai.exe" "dist-package\coderai.exe" -Force
Write-Ok "Packaged executable: dist-package\coderai.exe"
Write-Warn "Target systems still need compatible GPU/runtime drivers such as CUDA when GPU backends are used."
}
switch ($Backend) {
"cuda" {
Install-CudaStack
Install-CommonMLStack
}
"cpu" {
Install-CpuStack
Install-CommonMLStack
}
default {
Install-CudaStack
Install-CommonMLStack
}
}
Set-Content -Path ".backend" -Value $Backend
if ($Package) {
Invoke-PackageBuild
}
Write-Ok "Build completed successfully!"
Write-Host ""
Write-Host "To activate the environment in the future, run:"
Write-Host " $venvActivate"
Write-Host ""
Write-Host "Recommended runtime notes:"
Write-Host " - Windows keeps CUDA as the primary NVIDIA acceleration path"
Write-Host " - Metal is macOS-only and not used on Windows"
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
# Build script for CoderAI - Supports NVIDIA (CUDA), Vulkan, OpenCL, and CPU backends # Build script for CoderAI - Supports NVIDIA (CUDA), Vulkan, OpenCL, and CPU backends
# Usage: ./build.sh [nvidia|vulkan|vulkan-nvidia|cuda|opencl|all] [--flash] [--venv <venv>] # Usage: ./build.sh [nvidia|vulkan|vulkan-nvidia|cuda|opencl|all] [--flash] [--venv <venv>] [--package]
# Default: all (installs all backends) # Default: all (installs all backends)
# --flash: Enable and install Flash Attention 2 (for NVIDIA GPUs) # --flash: Enable and install Flash Attention 2 (for NVIDIA GPUs)
# --venv <venv>: Specify custom virtual environment name # --venv <venv>: Specify custom virtual environment name
...@@ -34,6 +34,7 @@ NC='\033[0m' # No Color ...@@ -34,6 +34,7 @@ NC='\033[0m' # No Color
BACKEND="${1:-all}" BACKEND="${1:-all}"
FLASH=false FLASH=false
CUSTOM_VENV="" CUSTOM_VENV=""
PACKAGE=false
# Parse arguments # Parse arguments
i=1 i=1
...@@ -46,6 +47,9 @@ for arg in "$@"; do ...@@ -46,6 +47,9 @@ for arg in "$@"; do
i=$((i + 1)) i=$((i + 1))
eval "CUSTOM_VENV=\${$i}" eval "CUSTOM_VENV=\${$i}"
;; ;;
--package)
PACKAGE=true
;;
esac esac
i=$((i + 1)) i=$((i + 1))
done done
...@@ -719,10 +723,36 @@ elif [ "$BACKEND" = "all" ]; then ...@@ -719,10 +723,36 @@ elif [ "$BACKEND" = "all" ]; then
echo "" echo ""
fi fi
package_app() {
echo -e "${YELLOW}Packaging CoderAI with PyInstaller...${NC}"
pip install pyinstaller
mkdir -p dist-package
pyinstaller --clean --noconfirm --onefile --name coderai \
--collect-all codai \
--collect-all fastapi \
--collect-all uvicorn \
--collect-all pydantic \
--collect-all transformers \
--collect-all diffusers \
--collect-all sentence_transformers \
--collect-all whispercpp \
--collect-all insightface \
--collect-all onnxruntime \
--collect-all PIL \
coderai
cp dist/coderai dist-package/coderai
echo -e "${GREEN}✓ Packaged executable: dist-package/coderai${NC}"
echo -e "${YELLOW}Note: The target machine must still provide compatible system GPU/runtime libraries.${NC}"
}
# Create .backend file to track which backend was used # Create .backend file to track which backend was used
echo "$BACKEND" > .backend echo "$BACKEND" > .backend
if [ "$PACKAGE" = true ]; then
package_app
fi
echo -e "${GREEN}Build completed successfully!${NC}" echo -e "${GREEN}Build completed successfully!${NC}"
echo "" echo ""
echo "To activate the environment in the future, run:" echo "To activate the environment in the future, run:"
echo " source $VENV_DIR/bin/activate" echo " source $VENV_DIR/bin/activate"
\ No newline at end of file
...@@ -54,7 +54,10 @@ def get_or_create_secret(config_dir: Path) -> bytes: ...@@ -54,7 +54,10 @@ def get_or_create_secret(config_dir: Path) -> bytes:
secret = secrets.token_bytes(32) secret = secrets.token_bytes(32)
with open(secret_path, 'wb') as f: with open(secret_path, 'wb') as f:
f.write(secret) f.write(secret)
secret_path.chmod(0o600) try:
secret_path.chmod(0o600)
except OSError:
pass
return secret return secret
......
...@@ -25,6 +25,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, Stre ...@@ -25,6 +25,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, Stre
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from codai.admin.auth import SessionManager from codai.admin.auth import SessionManager
from codai.platform_paths import default_whisper_server_path
import queue as _q import queue as _q
import threading as _t import threading as _t
import uuid as _uuid import uuid as _uuid
...@@ -90,7 +91,7 @@ def _next_whisper_server_model_id(audio_models) -> str: ...@@ -90,7 +91,7 @@ def _next_whisper_server_model_id(audio_models) -> str:
def _default_whisper_server_path() -> 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]: def get_current_user(request: Request) -> Optional[str]:
...@@ -239,7 +240,11 @@ async def admin_dashboard(request: Request, username: str = Depends(require_auth ...@@ -239,7 +240,11 @@ async def admin_dashboard(request: Request, username: str = Depends(require_auth
@router.get("/admin/models", response_class=HTMLResponse) @router.get("/admin/models", response_class=HTMLResponse)
async def models_page(request: Request, username: str = Depends(require_admin)): 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) @router.get("/admin/tokens", response_class=HTMLResponse)
...@@ -1434,12 +1439,14 @@ async def api_model_configure(request: Request, username: str = Depends(require_ ...@@ -1434,12 +1439,14 @@ async def api_model_configure(request: Request, username: str = Depends(require_
gpu_device = int(data.get("gpu_device", 0)) gpu_device = int(data.get("gpu_device", 0))
if gpu_device < 0: if gpu_device < 0:
raise HTTPException(status_code=400, detail="gpu_device must be >= 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", []) audio_list = config_manager.models_data.get("audio_models", [])
config_manager.models_data["audio_models"] = [ if any(
m for m in audio_list isinstance(m, dict)
if not (isinstance(m, dict) and m.get("id") == model_id) 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 alias = (data.get("alias") or "").strip() or None
entry = { entry = {
"id": model_id, "id": model_id,
...@@ -1617,6 +1624,22 @@ async def api_get_settings(username: str = Depends(require_admin)): ...@@ -1617,6 +1624,22 @@ async def api_get_settings(username: str = Depends(require_admin)):
"directory": c.archive.directory, "directory": c.archive.directory,
"retention": c.archive.retention, "retention": c.archive.retention,
}, },
"broker": {
"enabled": c.broker.enabled,
"base_url": c.broker.base_url,
"scope": c.broker.scope,
"username": c.broker.username,
"provider_id": c.broker.provider_id,
"client_id": c.broker.client_id,
"registration_token": c.broker.registration_token,
"advertised_endpoint": c.broker.advertised_endpoint,
"transport": c.broker.transport,
"heartbeat_interval_seconds": c.broker.heartbeat_interval_seconds,
"connect_timeout_seconds": c.broker.connect_timeout_seconds,
"request_timeout_seconds": c.broker.request_timeout_seconds,
"reconnect_initial_delay_seconds": c.broker.reconnect_initial_delay_seconds,
"reconnect_max_delay_seconds": c.broker.reconnect_max_delay_seconds,
},
"system_prompt": c.system_prompt, "system_prompt": c.system_prompt,
"tools_closer_prompt": c.tools_closer_prompt, "tools_closer_prompt": c.tools_closer_prompt,
"grammar_guided": c.grammar_guided, "grammar_guided": c.grammar_guided,
...@@ -1707,6 +1730,28 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad ...@@ -1707,6 +1730,28 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
resolved = raw_dir if raw_dir and _os.path.isabs(raw_dir) else _os.path.join(cfg_dir, raw_dir or "archive") resolved = raw_dir if raw_dir and _os.path.isabs(raw_dir) else _os.path.join(cfg_dir, raw_dir or "archive")
archive_manager.configure(c.archive.enabled, resolved, c.archive.retention) archive_manager.configure(c.archive.enabled, resolved, c.archive.retention)
if "broker" in data:
bro = data["broker"]
c.broker.enabled = bool(bro.get("enabled", c.broker.enabled))
c.broker.base_url = (bro.get("base_url") or "").strip()
c.broker.scope = (bro.get("scope") or c.broker.scope or "user").strip()
c.broker.username = (bro.get("username") or "").strip()
c.broker.provider_id = (bro.get("provider_id") or "").strip()
c.broker.client_id = (bro.get("client_id") or "").strip()
c.broker.registration_token = (bro.get("registration_token") or "").strip()
c.broker.advertised_endpoint = (bro.get("advertised_endpoint") or "").strip()
c.broker.transport = (bro.get("transport") or c.broker.transport or "websocket").strip()
c.broker.heartbeat_interval_seconds = max(1, int(bro.get("heartbeat_interval_seconds", c.broker.heartbeat_interval_seconds)))
c.broker.connect_timeout_seconds = max(1, int(bro.get("connect_timeout_seconds", c.broker.connect_timeout_seconds)))
c.broker.request_timeout_seconds = max(1, int(bro.get("request_timeout_seconds", c.broker.request_timeout_seconds)))
c.broker.reconnect_initial_delay_seconds = max(1, int(bro.get("reconnect_initial_delay_seconds", c.broker.reconnect_initial_delay_seconds)))
c.broker.reconnect_max_delay_seconds = max(
c.broker.reconnect_initial_delay_seconds,
int(bro.get("reconnect_max_delay_seconds", c.broker.reconnect_max_delay_seconds)),
)
from codai.broker.config import build_broker_runtime_config
request.app.state.broker_runtime = build_broker_runtime_config(c.broker)
config_manager.save_config() config_manager.save_config()
return {"success": True} return {"success": True}
......
...@@ -26,6 +26,9 @@ ...@@ -26,6 +26,9 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<script>
window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }};
</script>
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>Models</h1> <h1>Models</h1>
...@@ -1253,7 +1256,7 @@ function nextWhisperServerModelId(){ ...@@ -1253,7 +1256,7 @@ function nextWhisperServerModelId(){
} }
function defaultWhisperServerPath(){ function defaultWhisperServerPath(){
return '/usr/local/bin/whisper-server'; return window.__DEFAULT_WHISPER_SERVER_PATH__ || '';
} }
function resetWhisperServerBuilderDefaults(){ function resetWhisperServerBuilderDefaults(){
......
...@@ -101,6 +101,81 @@ ...@@ -101,6 +101,81 @@
<span class="form-hint">Archived entries older than this are automatically deleted. Takes effect immediately on save.</span> <span class="form-hint">Archived entries older than this are automatically deleted. Takes effect immediately on save.</span>
</div> </div>
</div> </div>
<div class="card mb-0" style="margin-top:1rem">
<div class="card-title">AISBF Broker</div>
<div class="form-row">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
<input type="checkbox" id="s-broker-enabled" onchange="toggleBrokerFields()">
<span style="font-size:13px;font-weight:500">Enable broker client</span>
</label>
<span class="form-hint">Registers this CoderAI instance with an AISBF broker so it can receive brokered requests.</span>
</div>
<div id="broker-fields" style="display:none">
<div class="form-row">
<label class="form-label">Broker base URL</label>
<input type="text" id="s-broker-base-url" class="form-input" placeholder="https://broker.example.com">
<span class="form-hint">Supports `http`, `https`, `ws`, or `wss`. The websocket endpoint is derived automatically.</span>
</div>
<div style="display:grid;grid-template-columns:180px 1fr;gap:1rem;align-items:start">
<div class="form-row" style="margin:0">
<label class="form-label">Scope</label>
<select id="s-broker-scope" class="form-input">
<option value="user">user</option>
<option value="global">global</option>
</select>
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Username</label>
<input type="text" id="s-broker-username" class="form-input" placeholder="alice or global">
<span class="form-hint">Use `global` when scope is `global`; otherwise provide the AISBF username.</span>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;align-items:start">
<div class="form-row" style="margin:0">
<label class="form-label">Provider ID</label>
<input type="text" id="s-broker-provider-id" class="form-input" placeholder="coderai-local">
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Client ID</label>
<input type="text" id="s-broker-client-id" class="form-input" placeholder="workstation-01">
</div>
</div>
<div class="form-row">
<label class="form-label">Registration token</label>
<input type="password" id="s-broker-registration-token" class="form-input" placeholder="Broker registration token">
</div>
<div class="form-row">
<label class="form-label">Advertised endpoint</label>
<input type="text" id="s-broker-advertised-endpoint" class="form-input" placeholder="http://127.0.0.1:8776">
<span class="form-hint">Optional external URL advertised to the broker for this instance.</span>
</div>
<div style="display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:1rem;align-items:start">
<div class="form-row" style="margin:0">
<label class="form-label">Heartbeat seconds</label>
<input type="number" id="s-broker-heartbeat" class="form-input" min="1" placeholder="30">
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Connect timeout</label>
<input type="number" id="s-broker-connect-timeout" class="form-input" min="1" placeholder="10">
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Request timeout</label>
<input type="number" id="s-broker-request-timeout" class="form-input" min="1" placeholder="30">
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:1rem;align-items:start">
<div class="form-row" style="margin:0">
<label class="form-label">Reconnect initial delay</label>
<input type="number" id="s-broker-reconnect-initial" class="form-input" min="1" placeholder="1">
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Reconnect max delay</label>
<input type="number" id="s-broker-reconnect-max" class="form-input" min="1" placeholder="60">
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
...@@ -110,6 +185,11 @@ function toggleHttps(){ ...@@ -110,6 +185,11 @@ function toggleHttps(){
document.getElementById('s-https').checked ? 'block' : 'none'; document.getElementById('s-https').checked ? 'block' : 'none';
} }
function toggleBrokerFields(){
document.getElementById('broker-fields').style.display =
document.getElementById('s-broker-enabled').checked ? 'block' : 'none';
}
function showAlert(type, msg){ function showAlert(type, msg){
const el = document.getElementById('settings-alert'); const el = document.getElementById('settings-alert');
el.className = 'alert alert-' + (type === 'error' ? 'error' : 'info'); el.className = 'alert alert-' + (type === 'error' ? 'error' : 'info');
...@@ -143,6 +223,21 @@ async function loadSettings(){ ...@@ -143,6 +223,21 @@ async function loadSettings(){
if (hint && as.default_directory) hint.textContent = `(default: ${as.default_directory})`; if (hint && as.default_directory) hint.textContent = `(default: ${as.default_directory})`;
document.getElementById('s-arc-dir').placeholder = as.default_directory || '(default)'; document.getElementById('s-arc-dir').placeholder = as.default_directory || '(default)';
} catch(_){} } catch(_){}
const broker = d.broker || {};
document.getElementById('s-broker-enabled').checked = !!broker.enabled;
document.getElementById('s-broker-base-url').value = broker.base_url ?? '';
document.getElementById('s-broker-scope').value = broker.scope ?? 'user';
document.getElementById('s-broker-username').value = broker.username ?? '';
document.getElementById('s-broker-provider-id').value = broker.provider_id ?? '';
document.getElementById('s-broker-client-id').value = broker.client_id ?? '';
document.getElementById('s-broker-registration-token').value = broker.registration_token ?? '';
document.getElementById('s-broker-advertised-endpoint').value = broker.advertised_endpoint ?? '';
document.getElementById('s-broker-heartbeat').value = broker.heartbeat_interval_seconds ?? 30;
document.getElementById('s-broker-connect-timeout').value = broker.connect_timeout_seconds ?? 10;
document.getElementById('s-broker-request-timeout').value = broker.request_timeout_seconds ?? 30;
document.getElementById('s-broker-reconnect-initial').value = broker.reconnect_initial_delay_seconds ?? 1;
document.getElementById('s-broker-reconnect-max').value = broker.reconnect_max_delay_seconds ?? 60;
toggleBrokerFields();
}catch(e){ showAlert('error','Failed to load settings: '+e.message); } }catch(e){ showAlert('error','Failed to load settings: '+e.message); }
} }
...@@ -169,6 +264,22 @@ async function saveSettings(){ ...@@ -169,6 +264,22 @@ async function saveSettings(){
directory: document.getElementById('s-arc-dir').value.trim(), directory: document.getElementById('s-arc-dir').value.trim(),
retention: document.getElementById('s-arc-retention').value, retention: document.getElementById('s-arc-retention').value,
}, },
broker:{
enabled: document.getElementById('s-broker-enabled').checked,
base_url: document.getElementById('s-broker-base-url').value.trim(),
scope: document.getElementById('s-broker-scope').value,
username: document.getElementById('s-broker-username').value.trim(),
provider_id: document.getElementById('s-broker-provider-id').value.trim(),
client_id: document.getElementById('s-broker-client-id').value.trim(),
registration_token: document.getElementById('s-broker-registration-token').value.trim(),
advertised_endpoint: document.getElementById('s-broker-advertised-endpoint').value.trim(),
heartbeat_interval_seconds: parseInt(document.getElementById('s-broker-heartbeat').value) || 30,
connect_timeout_seconds: parseInt(document.getElementById('s-broker-connect-timeout').value) || 10,
request_timeout_seconds: parseInt(document.getElementById('s-broker-request-timeout').value) || 30,
reconnect_initial_delay_seconds: parseInt(document.getElementById('s-broker-reconnect-initial').value) || 1,
reconnect_max_delay_seconds: parseInt(document.getElementById('s-broker-reconnect-max').value) || 60,
transport: 'websocket',
},
}; };
try{ try{
const r = await fetch(ROOT_PATH + '/admin/api/settings',{ const r = await fetch(ROOT_PATH + '/admin/api/settings',{
......
...@@ -40,6 +40,8 @@ from typing import List, Optional ...@@ -40,6 +40,8 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from codai.platform_paths import default_characters_dir, legacy_style_config_dir
router = APIRouter() router = APIRouter()
_CHARS_DIR: Optional[str] = None _CHARS_DIR: Optional[str] = None
...@@ -47,8 +49,8 @@ _CHARS_DIR: Optional[str] = None ...@@ -47,8 +49,8 @@ _CHARS_DIR: Optional[str] = None
def set_global_args(args): def set_global_args(args):
global _CHARS_DIR global _CHARS_DIR
base = getattr(args, 'file_path', None) or 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 os.path.expanduser('~/.coderai')) 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') _CHARS_DIR = os.path.join(root, 'characters')
os.makedirs(_CHARS_DIR, exist_ok=True) os.makedirs(_CHARS_DIR, exist_ok=True)
...@@ -60,7 +62,7 @@ def set_global_file_path(path: str): ...@@ -60,7 +62,7 @@ def set_global_file_path(path: str):
def _chars_dir() -> str: def _chars_dir() -> str:
if _CHARS_DIR: if _CHARS_DIR:
return _CHARS_DIR return _CHARS_DIR
d = os.path.expanduser('~/.coderai/characters') d = str(default_characters_dir())
os.makedirs(d, exist_ok=True) os.makedirs(d, exist_ok=True)
return d return d
......
...@@ -42,6 +42,8 @@ from typing import List, Optional ...@@ -42,6 +42,8 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from codai.platform_paths import default_environments_dir, legacy_style_config_dir
router = APIRouter() router = APIRouter()
_ENVS_DIR: Optional[str] = None _ENVS_DIR: Optional[str] = None
...@@ -49,8 +51,8 @@ _ENVS_DIR: Optional[str] = None ...@@ -49,8 +51,8 @@ _ENVS_DIR: Optional[str] = None
def set_global_args(args): def set_global_args(args):
global _ENVS_DIR global _ENVS_DIR
base = getattr(args, 'file_path', None) or 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 os.path.expanduser('~/.coderai')) 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') _ENVS_DIR = os.path.join(root, 'environments')
os.makedirs(_ENVS_DIR, exist_ok=True) os.makedirs(_ENVS_DIR, exist_ok=True)
...@@ -62,7 +64,7 @@ def set_global_file_path(path: str): ...@@ -62,7 +64,7 @@ def set_global_file_path(path: str):
def _envs_dir() -> str: def _envs_dir() -> str:
if _ENVS_DIR: if _ENVS_DIR:
return _ENVS_DIR return _ENVS_DIR
d = os.path.expanduser('~/.coderai/environments') d = str(default_environments_dir())
os.makedirs(d, exist_ok=True) os.makedirs(d, exist_ok=True)
return d return d
......
...@@ -20,13 +20,14 @@ from PIL import Image ...@@ -20,13 +20,14 @@ from PIL import Image
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from codai.api.images import save_image_response from codai.api.images import save_image_response
from codai.platform_paths import default_insightface_model_path
router = APIRouter() router = APIRouter()
global_args = None global_args = None
global_file_path = 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_REPO = 'deepinsight/inswapper'
_INSWAPPER_HF_FILE = 'inswapper_128.onnx' _INSWAPPER_HF_FILE = 'inswapper_128.onnx'
......
...@@ -1713,7 +1713,8 @@ def _run_unpixelate(image_bytes: bytes, scale: int, model_path: Optional[str]) - ...@@ -1713,7 +1713,8 @@ def _run_unpixelate(image_bytes: bytes, scale: int, model_path: Optional[str]) -
mp = model_path mp = model_path
else: else:
# Download RealESRGAN_x4plus on demand # 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): if not os.path.exists(mp):
os.makedirs(os.path.dirname(mp), exist_ok=True) os.makedirs(os.path.dirname(mp), exist_ok=True)
import urllib.request import urllib.request
......
...@@ -136,9 +136,11 @@ async def create_transcription( ...@@ -136,9 +136,11 @@ async def create_transcription(
# Check if the requested model maps to a configured whisper-server instance first. # Check if the requested model maps to a configured whisper-server instance first.
# Try alias round-robin resolution before direct ID lookup. # Try alias round-robin resolution before direct ID lookup.
whisper_model_id = multi_model_manager.resolve_whisper_alias_model_id(model)
whisper_server = ( whisper_server = (
multi_model_manager.resolve_whisper_alias(model) multi_model_manager.whisper_servers.get(whisper_model_id)
or multi_model_manager.whisper_servers.get(model) if whisper_model_id is not None
else multi_model_manager.whisper_servers.get(model)
) )
if whisper_server is not None: if whisper_server is not None:
multi_model_manager.request_model(requested_model=model, model_type="audio") multi_model_manager.request_model(requested_model=model, model_type="audio")
...@@ -148,7 +150,7 @@ async def create_transcription( ...@@ -148,7 +150,7 @@ async def create_transcription(
gpu_device=getattr(whisper_server, "_gpu_device", 0), gpu_device=getattr(whisper_server, "_gpu_device", 0),
) )
if whisper_server.is_running(): 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.models[ws_key] = whisper_server
multi_model_manager.active_in_vram = ws_key multi_model_manager.active_in_vram = ws_key
multi_model_manager.models_in_vram.add(ws_key) multi_model_manager.models_in_vram.add(ws_key)
......
...@@ -22,6 +22,8 @@ from typing import Optional ...@@ -22,6 +22,8 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from codai.platform_paths import default_voices_dir
router = APIRouter() router = APIRouter()
global_args = None global_args = None
...@@ -35,8 +37,8 @@ def set_global_args(args): ...@@ -35,8 +37,8 @@ def set_global_args(args):
global global_args, _VOICES_DIR global global_args, _VOICES_DIR
global_args = args global_args = args
# Store voice profiles alongside output files, or in a default location # Store voice profiles alongside output files, or in a default location
base = getattr(args, 'file_path', None) or 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 os.path.expanduser('~/.coderai'), 'voices') _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) os.makedirs(_VOICES_DIR, exist_ok=True)
...@@ -48,7 +50,7 @@ def set_global_file_path(path): ...@@ -48,7 +50,7 @@ def set_global_file_path(path):
def _voices_dir() -> str: def _voices_dir() -> str:
if _VOICES_DIR: if _VOICES_DIR:
return _VOICES_DIR return _VOICES_DIR
d = os.path.expanduser('~/.coderai/voices') d = str(default_voices_dir())
os.makedirs(d, exist_ok=True) os.makedirs(d, exist_ok=True)
return d return d
......
...@@ -20,6 +20,8 @@ import json ...@@ -20,6 +20,8 @@ import json
import os import os
from pathlib import Path from pathlib import Path
from codai.platform_paths import legacy_style_config_dir
def load_config_file(config_dir: Path) -> dict: def load_config_file(config_dir: Path) -> dict:
"""Load the main config.json file.""" """Load the main config.json file."""
...@@ -166,11 +168,12 @@ def setup_default_config(config_dir: Path): ...@@ -166,11 +168,12 @@ def setup_default_config(config_dir: Path):
def parse_args(): def parse_args():
"""Parse command line arguments.""" """Parse command line arguments."""
default_config = str(legacy_style_config_dir())
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="OpenAI-compatible API server supporting NVIDIA (CUDA) and Vulkan backends", description="OpenAI-compatible API server supporting NVIDIA (CUDA) and Vulkan backends",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Configuration: All settings are loaded from JSON config files in the 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 config.json - Server and backend settings
models.json - Model registry and configurations models.json - Model registry and configurations
auth.json - Users, tokens, and sessions""" auth.json - Users, tokens, and sessions"""
...@@ -178,8 +181,8 @@ configuration directory (--config DIR, default: ~/.coderai/). Key files: ...@@ -178,8 +181,8 @@ configuration directory (--config DIR, default: ~/.coderai/). Key files:
parser.add_argument( parser.add_argument(
"--config", "--config",
type=str, type=str,
default=os.path.expanduser("~/.coderai/"), default=default_config,
help="Configuration directory (default: ~/.coderai/)", help=f"Configuration directory (default: {default_config})",
) )
parser.add_argument( parser.add_argument(
"--debug", "--debug",
...@@ -224,4 +227,4 @@ configuration directory (--config DIR, default: ~/.coderai/). Key files: ...@@ -224,4 +227,4 @@ configuration directory (--config DIR, default: ~/.coderai/). Key files:
action="store_true", action="store_true",
help="List available Vulkan GPU devices and exit", help="List available Vulkan GPU devices and exit",
) )
return parser.parse_args() return parser.parse_args()
\ No newline at end of file
...@@ -40,6 +40,8 @@ import hashlib ...@@ -40,6 +40,8 @@ import hashlib
import pathlib import pathlib
from typing import Optional, Dict, List, Tuple from typing import Optional, Dict, List, Tuple
from codai.platform_paths import default_model_cache_dir, legacy_style_cache_root
# For type hints # For type hints
import time import time
...@@ -49,8 +51,7 @@ def get_model_cache_dir() -> str: ...@@ -49,8 +51,7 @@ def get_model_cache_dir() -> str:
if os.environ.get('CODERAI_CACHE_DIR'): if os.environ.get('CODERAI_CACHE_DIR'):
cache_dir = os.environ['CODERAI_CACHE_DIR'] cache_dir = os.environ['CODERAI_CACHE_DIR']
else: else:
cache_home = os.environ.get('XDG_CACHE_HOME', os.path.expanduser('~/.cache')) cache_dir = str(default_model_cache_dir())
cache_dir = os.path.join(cache_home, 'coderai', 'models')
pathlib.Path(cache_dir).mkdir(parents=True, exist_ok=True) pathlib.Path(cache_dir).mkdir(parents=True, exist_ok=True)
return cache_dir return cache_dir
...@@ -58,7 +59,7 @@ def get_model_cache_dir() -> str: ...@@ -58,7 +59,7 @@ def get_model_cache_dir() -> str:
def get_all_cache_dirs() -> dict: def get_all_cache_dirs() -> dict:
"""Get all model cache directories.""" """Get all model cache directories."""
caches = {} caches = {}
cache_home = os.environ.get('XDG_CACHE_HOME', os.path.expanduser('~/.cache')) cache_home = str(legacy_style_cache_root())
# Coderai GGUF cache # Coderai GGUF cache
coderai_cache = get_model_cache_dir() coderai_cache = get_model_cache_dir()
...@@ -79,7 +80,7 @@ def get_all_cache_dirs() -> dict: ...@@ -79,7 +80,7 @@ def get_all_cache_dirs() -> dict:
caches['huggingface'] = hf_cache caches['huggingface'] = hf_cache
# Local diffusers 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): if os.path.exists(local_diffusers):
caches['diffusers'] = local_diffusers caches['diffusers'] = local_diffusers
...@@ -549,4 +550,4 @@ __all__ = [ ...@@ -549,4 +550,4 @@ __all__ = [
'remove_cached_model', 'remove_cached_model',
'list_cached_models_info', 'list_cached_models_info',
'remove_all_cached_models', 'remove_all_cached_models',
] ]
\ No newline at end of file
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
import os import os
import random
import subprocess import subprocess
import signal import signal
import requests import requests
...@@ -256,6 +257,7 @@ class WhisperServerManager: ...@@ -256,6 +257,7 @@ class WhisperServerManager:
self.current_model = None self.current_model = None
self.base_url = f"http://127.0.0.1:{port}" self.base_url = f"http://127.0.0.1:{port}"
self.lock = threading.Lock() self.lock = threading.Lock()
self._active_requests = 0
# Check if port is available # Check if port is available
if not self._is_port_available(port): if not self._is_port_available(port):
...@@ -351,7 +353,8 @@ class WhisperServerManager: ...@@ -351,7 +353,8 @@ class WhisperServerManager:
"""Send transcription request to whisper-server.""" """Send transcription request to whisper-server."""
if not self.is_running(): if not self.is_running():
return {"error": "whisper-server not running"} return {"error": "whisper-server not running"}
with self.lock:
self._active_requests += 1
try: try:
files = {"file": ("audio.wav", audio_data, "audio/wav")} files = {"file": ("audio.wav", audio_data, "audio/wav")}
data = {} data = {}
...@@ -373,6 +376,16 @@ class WhisperServerManager: ...@@ -373,6 +376,16 @@ class WhisperServerManager:
return {"error": f"Server error: {response.status_code}", "detail": response.text} return {"error": f"Server error: {response.status_code}", "detail": response.text}
except Exception as e: except Exception as e:
return {"error": str(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: def _wait_for_server(self, timeout: int = 30) -> bool:
"""Wait for whisper-server to be ready.""" """Wait for whisper-server to be ready."""
...@@ -810,6 +823,17 @@ class MultiModelManager: ...@@ -810,6 +823,17 @@ class MultiModelManager:
idx = self._whisper_alias_counters.get(name, 0) % len(ids) idx = self._whisper_alias_counters.get(name, 0) % len(ids)
self._whisper_alias_counters[name] = idx + 1 self._whisper_alias_counters[name] = idx + 1
return self.whisper_servers.get(ids[idx]) 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): def set_tts_model(self, model_name: str, config: Dict = None):
"""Set the text-to-speech model and download/cache it if needed.""" """Set the text-to-speech model and download/cache it if needed."""
...@@ -900,6 +924,9 @@ class MultiModelManager: ...@@ -900,6 +924,9 @@ class MultiModelManager:
for m in self.audio_models: for m in self.audio_models:
allowed.add(m) allowed.add(m)
allowed.add(f"audio:{m}") allowed.add(f"audio:{m}")
for alias in self.whisper_aliases:
allowed.add(alias)
allowed.add(f"audio:{alias}")
# TTS model # TTS model
if self.tts_model: if self.tts_model:
...@@ -963,10 +990,18 @@ class MultiModelManager: ...@@ -963,10 +990,18 @@ class MultiModelManager:
"video_models", "audio_gen_models", "embedding_models", "video_models", "audio_gen_models", "embedding_models",
"spatial_models"): "spatial_models"):
for m in md.get(cat, []): for m in md.get(cat, []):
mid = (m if isinstance(m, str) else if isinstance(m, str):
m.get("alias") or m.get("path") or m.get("id") or "") 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 "") 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: if val:
allowed.add(val) allowed.add(val)
short = val.split("/")[-1] if "/" in val else 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
#!/bin/bash
# CoderAI macOS build script
# Usage: ./osxbuild.sh [metal|cpu|all] [--venv <venv>] [--package]
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
BACKEND="${1:-all}"
CUSTOM_VENV=""
PACKAGE=false
i=1
for arg in "$@"; do
case $arg in
--venv)
i=$((i + 1))
eval "CUSTOM_VENV=\${$i}"
;;
--package)
PACKAGE=true
;;
esac
i=$((i + 1))
done
BACKEND=$(echo "$BACKEND" | tr '[:upper:]' '[:lower:]')
if [[ "$BACKEND" != "metal" && "$BACKEND" != "cpu" && "$BACKEND" != "all" ]]; then
echo -e "${RED}Error: Invalid backend '$BACKEND'${NC}"
echo "Usage: ./osxbuild.sh [metal|cpu|all] [--venv <venv>]"
exit 1
fi
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} CoderAI macOS Build Script${NC}"
echo -e "${BLUE} Backend: ${GREEN}$BACKEND${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
PYTHON_BIN="${PYTHON_BIN:-python3}"
PYTHON_VERSION=$($PYTHON_BIN --version 2>&1 | python3 -c 'import sys,re; print(re.search(r"(\d+\.\d+)", sys.stdin.read()).group(1))')
echo -e "${GREEN}✓ Python version: $PYTHON_VERSION${NC}"
if [ -n "$CUSTOM_VENV" ]; then
VENV_DIR="$CUSTOM_VENV"
elif [ "$BACKEND" = "metal" ]; then
VENV_DIR="venv_osx_metal"
elif [ "$BACKEND" = "cpu" ]; then
VENV_DIR="venv_osx_cpu"
else
VENV_DIR="venv_osx_all"
fi
if [ ! -d "$VENV_DIR" ]; then
echo -e "${YELLOW}Creating virtual environment: $VENV_DIR${NC}"
"$PYTHON_BIN" -m venv "$VENV_DIR"
else
echo -e "${YELLOW}Using existing virtual environment: $VENV_DIR${NC}"
fi
source "$VENV_DIR/bin/activate"
export PIP_NO_INPUT=1
export PIP_REQUIRE_VIRTUALENV=1
python -m pip install --upgrade pip setuptools wheel
python -m pip install -r requirements.txt
install_common_ml_stack() {
python -m pip install "imageio[ffmpeg]" scipy soundfile sentence-transformers openai-whisper argostranslate edge-tts kokoro-tts timm || true
python -m pip install realesrgan basicsr || true
python -m pip install demucs deepfilternet rnnoise voicefixer || true
python -m pip install insightface onnxruntime-silicon || python -m pip install insightface onnxruntime || true
python -m pip install f5-tts seed-vc || true
python -m pip install audiocraft || true
}
install_metal_stack() {
echo -e "${YELLOW}Installing PyTorch with Apple Silicon / MPS support...${NC}"
python -m pip install torch torchvision torchaudio
echo -e "${YELLOW}Installing llama-cpp-python with Metal support...${NC}"
CMAKE_ARGS="-DGGML_METAL=ON" python -m pip install --upgrade llama-cpp-python --no-cache-dir || {
echo -e "${YELLOW}Warning: Metal build failed, installing CPU llama-cpp-python${NC}"
python -m pip install --upgrade llama-cpp-python
}
echo -e "${YELLOW}Installing stable-diffusion-cpp-python with Metal support...${NC}"
CMAKE_ARGS="-DSD_METAL=ON -DSD_WEBM=OFF" python -m pip install stable-diffusion-cpp-python --no-cache-dir || {
echo -e "${YELLOW}Warning: Metal stable-diffusion-cpp-python not available${NC}"
}
echo -e "${YELLOW}Installing whispercpp with Metal support when possible...${NC}"
python -m pip uninstall -y whispercpp >/dev/null 2>&1 || true
TMP_DIR="${TMPDIR:-/tmp}/coderai-whispercpp"
rm -rf "$TMP_DIR"
git clone --depth 1 https://github.com/ggerganov/whisper.cpp "$TMP_DIR" >/dev/null 2>&1 || true
if [ -d "$TMP_DIR/bindings/python" ]; then
(cd "$TMP_DIR/bindings/python" && CMAKE_ARGS="-DWHISPER_METAL=ON -DGGML_METAL=ON" python -m pip install . --no-cache-dir --force-reinstall) || \
(cd "$TMP_DIR/bindings/python" && python -m pip install . --no-cache-dir --force-reinstall) || true
fi
}
install_cpu_stack() {
echo -e "${YELLOW}Installing CPU-only runtime...${NC}"
python -m pip install torch torchvision torchaudio
python -m pip install --upgrade llama-cpp-python
python -m pip install stable-diffusion-cpp-python || true
}
package_app() {
echo -e "${YELLOW}Packaging CoderAI with PyInstaller...${NC}"
python -m pip install pyinstaller
mkdir -p dist-package
pyinstaller --clean --noconfirm --onefile --name coderai \
--collect-all codai \
--collect-all fastapi \
--collect-all uvicorn \
--collect-all pydantic \
--collect-all transformers \
--collect-all diffusers \
--collect-all sentence_transformers \
--collect-all whispercpp \
--collect-all insightface \
--collect-all onnxruntime \
--collect-all PIL \
coderai
pyinstaller --clean --noconfirm --windowed --name CoderAI \
--collect-all codai \
--collect-all fastapi \
--collect-all uvicorn \
--collect-all pydantic \
--collect-all transformers \
--collect-all diffusers \
--collect-all sentence_transformers \
--collect-all whispercpp \
--collect-all insightface \
--collect-all onnxruntime \
--collect-all PIL \
coderai
cp dist/coderai dist-package/coderai
if [ -d "dist/CoderAI.app" ]; then
rm -rf dist-package/CoderAI.app
cp -R dist/CoderAI.app dist-package/CoderAI.app
fi
echo -e "${GREEN}✓ Packaged CLI binary: dist-package/coderai${NC}"
echo -e "${GREEN}✓ Packaged macOS app bundle: dist-package/CoderAI.app${NC}"
echo -e "${YELLOW}Note: macOS equivalent packaging is a single CLI binary plus a .app bundle; target machines still need compatible GPU/runtime libraries.${NC}"
}
if [ "$BACKEND" = "metal" ]; then
install_metal_stack
install_common_ml_stack
elif [ "$BACKEND" = "cpu" ]; then
install_cpu_stack
install_common_ml_stack
else
install_metal_stack
install_common_ml_stack
fi
echo "$BACKEND" > .backend
if [ "$PACKAGE" = true ]; then
package_app
fi
echo -e "${GREEN}Build completed successfully!${NC}"
echo ""
echo "To activate the environment in the future, run:"
echo " source $VENV_DIR/bin/activate"
echo ""
echo "Recommended runtime notes:"
echo " - macOS uses Metal (MPS / GGML_METAL / SD_METAL) instead of CUDA"
echo " - NVIDIA CUDA is not the standard macOS path"
...@@ -247,6 +247,7 @@ def test_model_configure_allows_whisper_server_id_coexistence_with_other_audio_b ...@@ -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): def test_model_configure_defaults_missing_whisper_server_path_to_usr_local_bin(monkeypatch, tmp_path):
from codai.admin import routes from codai.admin import routes
from codai.platform_paths import default_whisper_server_path
cfg = _build_config(tmp_path) cfg = _build_config(tmp_path)
monkeypatch.setattr(routes, "config_manager", cfg, raising=False) 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 ...@@ -267,7 +268,7 @@ def test_model_configure_defaults_missing_whisper_server_path_to_usr_local_bin(m
) )
assert response.status_code == 200 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() app.dependency_overrides.clear()
...@@ -546,6 +547,67 @@ def test_transcription_requires_configured_whisper_server_model_id(): ...@@ -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() 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): def test_get_all_allowed_identifiers_includes_configured_whisper_server_id_without_legacy_alias(monkeypatch):
from codai.admin import routes from codai.admin import routes
from codai.models.manager import MultiModelManager from codai.models.manager import MultiModelManager
...@@ -673,7 +735,7 @@ def test_models_template_defines_whisper_server_builder_default_helpers(): ...@@ -673,7 +735,7 @@ def test_models_template_defines_whisper_server_builder_default_helpers():
template = Path("codai/admin/templates/models.html").read_text() template = Path("codai/admin/templates/models.html").read_text()
assert "function defaultWhisperServerPath()" in template 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 "function resetWhisperServerBuilderDefaults()" in template
assert "const modelIdInput = document.getElementById('ws-model-id');" in template assert "const modelIdInput = document.getElementById('ws-model-id');" in template
assert "const serverPathInput = document.getElementById('ws-server-path');" in template assert "const serverPathInput = document.getElementById('ws-server-path');" in template
...@@ -779,3 +841,15 @@ def test_settings_template_keeps_queue_size_control(): ...@@ -779,3 +841,15 @@ def test_settings_template_keeps_queue_size_control():
assert "Request queue max size" in template assert "Request queue max size" in template
assert "s-queue-max" in template assert "s-queue-max" in template
def test_settings_template_includes_broker_controls():
template = Path("codai/admin/templates/settings.html").read_text()
assert "AISBF Broker" in template
assert "s-broker-enabled" in template
assert "s-broker-base-url" in template
assert "s-broker-provider-id" in template
assert "s-broker-client-id" in template
assert "s-broker-registration-token" in template
assert "toggleBrokerFields()" 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