* fix(studio): persist connection model selections server-side Remote Studio clients could see saved connections but not their enabled model lists because models lived only in browser localStorage. Store models and available_models in llm_providers and sync them through the providers API so alternate clients inherit the same catalog state. Fixes #7281 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Hydrate external connections on chat startup (#7281) Extract provider sync logic into sync-external-providers.ts and call it from chat-page on mount so persisted model selections appear in the Connected picker without opening Settings → Connections first. * fix(studio): backfill connection models and preserve local options (#7298) Address Codex P2 on remote connection persistence: - Backfill localStorage model selections to /api/providers when backend rows still have empty models_json (legacy upgrades) - Carry promptCacheTtl and openaiContainerTtlMinutes through startup sync - Await hydratePersistedSettings before syncing on ChatPage mount Contract tests: 7 passed; npm run typecheck passed. * Tighten comments * Tighten comments --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
222 lines
6.8 KiB
Python
222 lines
6.8 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""SQLite storage for external LLM provider configurations.
|
|
|
|
Same pattern as studio_db.py (module-level functions, raw sqlite3, WAL,
|
|
per-function connections). API keys are NOT stored here: they live only in
|
|
the browser (localStorage) and are sent encrypted per-request.
|
|
|
|
Enabled model selections and discovered catalog IDs are stored server-side so
|
|
remote Studio clients see the same connection state (#7281).
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import sqlite3
|
|
import threading
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from utils.paths import studio_db_path, ensure_dir
|
|
|
|
_schema_lock = threading.Lock()
|
|
_schema_ready = False
|
|
|
|
|
|
def _encode_models_json(models: Optional[list[str]]) -> str:
|
|
if not models:
|
|
return "[]"
|
|
return json.dumps([str(model).strip() for model in models if str(model).strip()])
|
|
|
|
|
|
def _decode_models_json(raw: Optional[str]) -> list[str]:
|
|
if not raw:
|
|
return []
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return []
|
|
if not isinstance(parsed, list):
|
|
return []
|
|
return [str(model).strip() for model in parsed if str(model).strip()]
|
|
|
|
|
|
def _row_models(row: sqlite3.Row) -> tuple[list[str], list[str]]:
|
|
return (
|
|
_decode_models_json(row["models_json"] if "models_json" in row.keys() else None),
|
|
_decode_models_json(
|
|
row["available_models_json"] if "available_models_json" in row.keys() else None
|
|
),
|
|
)
|
|
|
|
|
|
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|
"""Create the llm_providers table if absent. Called once per process."""
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS llm_providers (
|
|
id TEXT NOT NULL PRIMARY KEY,
|
|
provider_type TEXT NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
base_url TEXT NOT NULL,
|
|
is_enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(llm_providers)").fetchall()}
|
|
if "models_json" not in existing_cols:
|
|
conn.execute("ALTER TABLE llm_providers ADD COLUMN models_json TEXT NOT NULL DEFAULT '[]'")
|
|
if "available_models_json" not in existing_cols:
|
|
conn.execute(
|
|
"ALTER TABLE llm_providers ADD COLUMN available_models_json TEXT NOT NULL DEFAULT '[]'"
|
|
)
|
|
|
|
|
|
def get_connection() -> sqlite3.Connection:
|
|
"""Open studio.db with WAL mode, create table once per process."""
|
|
global _schema_ready
|
|
db_path = studio_db_path()
|
|
ensure_dir(db_path.parent)
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
if not _schema_ready:
|
|
with _schema_lock:
|
|
if not _schema_ready:
|
|
try:
|
|
_ensure_schema(conn)
|
|
_schema_ready = True
|
|
except Exception:
|
|
conn.close()
|
|
raise
|
|
return conn
|
|
|
|
|
|
def create_provider(
|
|
id: str,
|
|
provider_type: str,
|
|
display_name: str,
|
|
base_url: str,
|
|
models: Optional[list[str]] = None,
|
|
available_models: Optional[list[str]] = None,
|
|
) -> None:
|
|
"""Insert a new provider configuration."""
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
conn = get_connection()
|
|
try:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO llm_providers (
|
|
id, provider_type, display_name, base_url,
|
|
models_json, available_models_json,
|
|
created_at, updated_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
id,
|
|
provider_type,
|
|
display_name,
|
|
base_url,
|
|
_encode_models_json(models),
|
|
_encode_models_json(available_models),
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def update_provider(
|
|
id: str,
|
|
display_name: Optional[str] = None,
|
|
base_url: Optional[str] = None,
|
|
is_enabled: Optional[bool] = None,
|
|
models: Optional[list[str]] = None,
|
|
available_models: Optional[list[str]] = None,
|
|
) -> bool:
|
|
"""Update fields on an existing provider. Returns True if a row was updated."""
|
|
updates = []
|
|
params = []
|
|
if display_name is not None:
|
|
updates.append("display_name = ?")
|
|
params.append(display_name)
|
|
if base_url is not None:
|
|
updates.append("base_url = ?")
|
|
params.append(base_url)
|
|
if is_enabled is not None:
|
|
updates.append("is_enabled = ?")
|
|
params.append(1 if is_enabled else 0)
|
|
if models is not None:
|
|
updates.append("models_json = ?")
|
|
params.append(_encode_models_json(models))
|
|
if available_models is not None:
|
|
updates.append("available_models_json = ?")
|
|
params.append(_encode_models_json(available_models))
|
|
if not updates:
|
|
return False
|
|
updates.append("updated_at = ?")
|
|
params.append(datetime.now(timezone.utc).isoformat())
|
|
params.append(id)
|
|
|
|
conn = get_connection()
|
|
try:
|
|
cursor = conn.execute(
|
|
f"UPDATE llm_providers SET {', '.join(updates)} WHERE id = ?",
|
|
params,
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def delete_provider(id: str) -> bool:
|
|
"""Delete a provider by ID. Returns True if a row was deleted."""
|
|
conn = get_connection()
|
|
try:
|
|
cursor = conn.execute("DELETE FROM llm_providers WHERE id = ?", (id,))
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def get_provider(id: str) -> Optional[dict]:
|
|
"""Fetch a single provider by ID."""
|
|
conn = get_connection()
|
|
try:
|
|
row = conn.execute("SELECT * FROM llm_providers WHERE id = ?", (id,)).fetchone()
|
|
if not row:
|
|
return None
|
|
data = dict(row)
|
|
models, available_models = _row_models(row)
|
|
data["models"] = models
|
|
data["available_models"] = available_models
|
|
return data
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def list_providers() -> list[dict]:
|
|
"""List all provider configurations, ordered by creation time."""
|
|
conn = get_connection()
|
|
try:
|
|
rows = conn.execute("SELECT * FROM llm_providers ORDER BY created_at").fetchall()
|
|
providers: list[dict] = []
|
|
for row in rows:
|
|
data = dict(row)
|
|
models, available_models = _row_models(row)
|
|
data["models"] = models
|
|
data["available_models"] = available_models
|
|
providers.append(data)
|
|
return providers
|
|
finally:
|
|
conn.close()
|